Skip to main content

oapi_codegen/lower/
direction.rs

1//! The request shape and the response shape that `readOnly` and `writeOnly`
2//! describe.
3//!
4//! OpenAPI marks a property `readOnly` when a response carries it and a request
5//! must not, and `writeOnly` for the opposite. The mark names a direction, not a
6//! value, so one struct cannot state both.
7//!
8//! Each model therefore drops the properties that its direction does not carry.
9//! A model that one direction reaches keeps its name. A model that both
10//! directions reach becomes `<Name>Request` and `<Name>Response`, and so does
11//! every model that both directions reach and that references it.
12//!
13//! `docs/design.md` states the rest of the reasoning.
14
15use std::collections::BTreeMap;
16use std::collections::BTreeSet;
17use std::collections::HashMap;
18
19use crate::emit::usage;
20use crate::ir::Alias;
21use crate::ir::Direction;
22use crate::ir::Enum;
23use crate::ir::EnumKind;
24use crate::ir::Field;
25use crate::ir::Item;
26use crate::ir::Module;
27use crate::ir::RequestPayload;
28use crate::ir::ResponseBody;
29use crate::ir::RustType;
30use crate::ir::Service;
31use crate::ir::Struct;
32use crate::ir::UnionVariant;
33use crate::naming::Case;
34use crate::naming::RustIdent;
35use crate::naming::to_ident;
36
37/// The suffix the request shape of a split model takes.
38pub const REQUEST_SUFFIX: &str = "Request";
39/// The suffix the response shape of a split model takes.
40pub const RESPONSE_SUFFIX: &str = "Response";
41
42/// How one direction-sensitive model is projected.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum Projection {
45    /// Both directions reach the model, so it becomes two items with suffixed
46    /// names.
47    Split,
48    /// One direction reaches the model, so it keeps its name and drops only the
49    /// properties that direction does not carry.
50    Single(Direction),
51}
52
53/// Project every model a direction mark reaches onto the direction that carries
54/// it, and point each API position at the shape it takes.
55///
56/// `service` is `None` for a models-only run, which has no operation to give a
57/// direction. The pass splits every marked model there, because the run that
58/// consumes those models does know the direction.
59///
60/// The pass leaves a document that marks no property untouched.
61pub fn split_by_direction(module: &mut Module, service: Option<&mut Service>) {
62    let projections = projections(module, service.as_deref());
63    if projections.is_empty() {
64        return;
65    }
66    let mut items = Vec::with_capacity(module.items.len() + projections.len());
67    for item in module.items.drain(..) {
68        match projections.get(item.name()) {
69            Some(Projection::Split) => {
70                items.push(project_item(&item, Direction::Request, &projections));
71                items.push(project_item(&item, Direction::Response, &projections));
72            }
73            Some(Projection::Single(direction)) => items.push(project_item(&item, *direction, &projections)),
74            None => items.push(item),
75        }
76    }
77    module.items = items;
78    if let Some(service) = service {
79        project_service(service, &projections);
80    }
81}
82
83/// The name a model takes in one direction.
84pub fn projected_name(name: &str, direction: Direction) -> RustIdent {
85    let suffix = match direction {
86        Direction::Request => REQUEST_SUFFIX,
87        Direction::Response => RESPONSE_SUFFIX,
88    };
89    return to_ident(&format!("{name} {suffix}"), Case::Pascal);
90}
91
92/// How each direction-sensitive model is projected, keyed by its logical name.
93fn projections(module: &Module, service: Option<&Service>) -> BTreeMap<String, Projection> {
94    let sensitive = sensitive_models(module);
95    let Some(service) = service else {
96        return sensitive
97            .into_iter()
98            .map(|name| return (name, Projection::Split))
99            .collect();
100    };
101
102    let usage = usage::direction_usage(module, service);
103    return sensitive
104        .into_iter()
105        .map(|name| {
106            let used = usage.get(&name).copied().unwrap_or_default();
107            let projection = match (used.request, used.response) {
108                (true, false) => Projection::Single(Direction::Request),
109                (false, true) => Projection::Single(Direction::Response),
110                _ => Projection::Split,
111            };
112            return (name, projection);
113        })
114        .collect();
115}
116
117/// The models a direction mark reaches: the ones that mark a property, and
118/// every model that references one of those.
119///
120/// The walk starts at a marked model and follows the reference graph in
121/// reverse. The field type of a holder is what changes per direction.
122fn sensitive_models(module: &Module) -> BTreeSet<String> {
123    let mut marked: BTreeSet<String> = module
124        .items
125        .iter()
126        .filter(|item| return marks_a_direction(item))
127        .map(|item| return item.name().to_owned())
128        .collect();
129    if marked.is_empty() {
130        return marked;
131    }
132
133    let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
134    for (name, targets) in usage::adjacency(module) {
135        for target in targets {
136            referrers.entry(target).or_default().push(name.clone());
137        }
138    }
139
140    let mut stack: Vec<String> = marked.iter().cloned().collect();
141    while let Some(name) = stack.pop() {
142        let Some(parents) = referrers.get(&name) else {
143            continue;
144        };
145        for parent in parents {
146            if marked.insert(parent.clone()) {
147                stack.push(parent.clone());
148            }
149        }
150    }
151    return marked;
152}
153
154/// Whether an item declares a property that only one direction carries.
155fn marks_a_direction(item: &Item) -> bool {
156    let Item::Struct(strukt) = item else {
157        return false;
158    };
159    return strukt.fields.iter().any(|field| {
160        return field.access != crate::ir::Access::ReadWrite;
161    });
162}
163
164/// Build the shape of an item for one direction.
165///
166/// The name takes the suffix of the direction only when both directions reach
167/// the model. The shape drops the properties that the other direction carries,
168/// and points every reference to a split model at the shape of that model.
169fn project_item(item: &Item, direction: Direction, projections: &BTreeMap<String, Projection>) -> Item {
170    let split = matches!(projections.get(item.name()), Some(Projection::Split));
171    let name_of = |name: &RustIdent| {
172        if split {
173            return projected_name(name.logical(), direction);
174        }
175        return name.clone();
176    };
177    return match item {
178        Item::Struct(strukt) => {
179            let fields: Vec<Field> = strukt
180                .fields
181                .iter()
182                .filter(|field| return field.access.carried_by(direction))
183                .map(|field| {
184                    let mut projected = field.clone();
185                    projected.ty = project_type(&field.ty, direction, projections);
186                    return projected;
187                })
188                .collect();
189            let dropped = fields.len() < strukt.fields.len();
190            Item::Struct(Struct {
191                name: name_of(&strukt.name),
192                doc: projected_doc(&strukt.doc, strukt.name.logical(), direction, split, dropped),
193                fields,
194                additional_properties: strukt
195                    .additional_properties
196                    .as_ref()
197                    .map(|ty| return project_type(ty, direction, projections)),
198                ..strukt.clone()
199            })
200        }
201        Item::Enum(enom) => Item::Enum(Enum {
202            name: name_of(&enom.name),
203            doc: projected_doc(&enom.doc, enom.name.logical(), direction, split, false),
204            kind: match &enom.kind {
205                EnumKind::Union(variants) | EnumKind::AnyOf(variants) => {
206                    let variants = variants
207                        .iter()
208                        .map(|variant| {
209                            return UnionVariant {
210                                name: variant.name.clone(),
211                                ty: project_type(&variant.ty, direction, projections),
212                            };
213                        })
214                        .collect();
215                    if matches!(enom.kind, EnumKind::AnyOf(_)) {
216                        EnumKind::AnyOf(variants)
217                    } else {
218                        EnumKind::Union(variants)
219                    }
220                }
221                other => other.clone(),
222            },
223            ..enom.clone()
224        }),
225        Item::Alias(alias) => Item::Alias(Alias {
226            name: name_of(&alias.name),
227            doc: projected_doc(&alias.doc, alias.name.logical(), direction, split, false),
228            ty: project_type(&alias.ty, direction, projections),
229            ..alias.clone()
230        }),
231    };
232}
233
234/// Add a line about the direction, after the schema `description`.
235///
236/// A split shape always takes a line, because a reader meets two types where the
237/// document declares one schema. The line names the keyword only when the
238/// projection drops a property. A holder that splits because it references a
239/// split model drops nothing, so its line states the direction alone.
240///
241/// A model that keeps its name takes a line only when the projection drops a
242/// property. So a mark that costs a shape nothing leaves the generated file as
243/// it was.
244fn projected_doc(doc: &Option<String>, name: &str, direction: Direction, split: bool, dropped: bool) -> Option<String> {
245    let note = match (split, dropped, direction) {
246        (true, true, Direction::Request) => {
247            format!("The request shape of `{name}`. A `readOnly` property is not part of it.")
248        }
249        (true, true, Direction::Response) => {
250            format!("The response shape of `{name}`. A `writeOnly` property is not part of it.")
251        }
252        (true, false, Direction::Request) => format!("The request shape of `{name}`."),
253        (true, false, Direction::Response) => format!("The response shape of `{name}`."),
254        (false, false, _) => return doc.clone(),
255        (false, true, Direction::Request) => {
256            "Only a request carries this model, so a `readOnly` property is not part of it.".to_owned()
257        }
258        (false, true, Direction::Response) => {
259            "Only a response carries this model, so a `writeOnly` property is not part of it.".to_owned()
260        }
261    };
262    return Some(match doc {
263        Some(text) => format!("{text}\n\n{note}"),
264        None => note,
265    });
266}
267
268/// Rewrite a type expression so a reference to a split model names the shape of
269/// that model for `direction`. This leaves any other type as it is.
270fn project_type(ty: &RustType, direction: Direction, projections: &BTreeMap<String, Projection>) -> RustType {
271    return match ty {
272        RustType::Named(name) => {
273            // A `RustType::Named` holds the schema name the document wrote, so the
274            // lookup canonicalizes it. Without that, a reference to `order-item`
275            // would miss the `OrderItem` entry and keep the unsplit name.
276            let canonical = to_ident(name, Case::Pascal);
277            match projections.get(canonical.logical()) {
278                Some(Projection::Split) => {
279                    RustType::Named(projected_name(canonical.logical(), direction).logical().to_owned())
280                }
281                _ => ty.clone(),
282            }
283        }
284        RustType::Vec(inner) => RustType::Vec(Box::new(project_type(inner, direction, projections))),
285        RustType::Map(inner) => RustType::Map(Box::new(project_type(inner, direction, projections))),
286        RustType::Option(inner) => RustType::Option(Box::new(project_type(inner, direction, projections))),
287        RustType::Nullable(inner) => RustType::Nullable(Box::new(project_type(inner, direction, projections))),
288        RustType::Boxed(inner) => RustType::Boxed(Box::new(project_type(inner, direction, projections))),
289        other => other.clone(),
290    };
291}
292
293/// Point every API position at the shape of the direction it carries.
294fn project_service(service: &mut Service, projections: &BTreeMap<String, Projection>) {
295    for operation in &mut service.operations {
296        for param in &mut operation.path_params {
297            param.ty = project_type(&param.ty, Direction::Request, projections);
298        }
299        if let Some(query) = &mut operation.query {
300            project_struct(query, Direction::Request, projections);
301        }
302        if let Some(headers) = &mut operation.headers {
303            for param in &mut headers.params {
304                param.ty = project_type(&param.ty, Direction::Request, projections);
305            }
306        }
307        if let Some(cookies) = &mut operation.cookies {
308            for param in &mut cookies.params {
309                param.ty = project_type(&param.ty, Direction::Request, projections);
310            }
311        }
312        if let Some(request) = &mut operation.request {
313            match request {
314                RequestPayload::Single(body) => body.ty = project_type(&body.ty, Direction::Request, projections),
315                RequestPayload::Multipart(multipart) => {
316                    for field in &mut multipart.fields {
317                        field.ty = project_type(&field.ty, Direction::Request, projections);
318                    }
319                }
320                RequestPayload::Negotiated(negotiated) => {
321                    for variant in &mut negotiated.variants {
322                        variant.body.ty = project_type(&variant.body.ty, Direction::Request, projections);
323                    }
324                }
325            }
326        }
327        for case in &mut operation.responses {
328            for header in &mut case.headers {
329                header.ty = project_type(&header.ty, Direction::Response, projections);
330            }
331            match &mut case.body {
332                Some(ResponseBody::Single(body)) => body.ty = project_type(&body.ty, Direction::Response, projections),
333                Some(ResponseBody::Negotiated(negotiated)) => {
334                    for variant in &mut negotiated.variants {
335                        variant.body.ty = project_type(&variant.body.ty, Direction::Response, projections);
336                    }
337                }
338                None => {}
339            }
340        }
341    }
342}
343
344/// Rewrite the type of every field of a per-operation struct.
345fn project_struct(strukt: &mut Struct, direction: Direction, projections: &BTreeMap<String, Projection>) {
346    for field in &mut strukt.fields {
347        field.ty = project_type(&field.ty, direction, projections);
348    }
349    if let Some(additional) = &mut strukt.additional_properties {
350        *additional = project_type(additional, direction, projections);
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use std::path::PathBuf;
357
358    use super::*;
359    use crate::ir::Access;
360    use crate::ir::Field;
361    use crate::loader::Spec;
362
363    /// Lower the schemas of an inline document and split them, the way a
364    /// models-only run does.
365    fn split_yaml(yaml: &str) -> Module {
366        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
367        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
368        let names = crate::lower::rename::type_renames(&spec, None).expect("resolve names");
369        let mut module = crate::lower::generate_models(&spec, &names).expect("lower schemas");
370        split_by_direction(&mut module, None);
371        return module;
372    }
373
374    /// Lower an inline document with its operations and split them, the way a
375    /// server or client run does.
376    fn split_service_yaml(yaml: &str) -> Module {
377        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
378        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
379        let names = crate::lower::rename::type_renames(&spec, None).expect("resolve names");
380        let mut module = crate::lower::generate_models(&spec, &names).expect("lower schemas");
381        let mut service =
382            crate::lower::generate_service(&spec, &Default::default(), "Response").expect("lower operations");
383        crate::lower::rewrite_service(&mut service, names.renames());
384        split_by_direction(&mut module, Some(&mut service));
385        return module;
386    }
387
388    /// The doc text of the item called `name`.
389    fn doc(module: &Module, name: &str) -> Option<String> {
390        for item in &module.items {
391            if item.name() == name {
392                return match item {
393                    Item::Struct(strukt) => strukt.doc.clone(),
394                    Item::Enum(enom) => enom.doc.clone(),
395                    Item::Alias(alias) => alias.doc.clone(),
396                };
397            }
398        }
399        panic!("no item named `{name}`");
400    }
401
402    /// A document whose only operation returns `Account`, so no request reaches
403    /// it.
404    fn response_only(schemas: &str) -> String {
405        return format!(
406            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths:\n  /accounts:\n    get:\n      operationId: listAccounts\n      responses:\n        '200':\n          description: ok\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Account'\ncomponents:\n  schemas:\n{schemas}"
407        );
408    }
409
410    /// The item names a module declares, in emission order.
411    fn names(module: &Module) -> Vec<String> {
412        return module.items.iter().map(|item| return item.name().to_owned()).collect();
413    }
414
415    /// The field names of the struct called `name`.
416    fn fields(module: &Module, name: &str) -> Vec<String> {
417        for item in &module.items {
418            if let Item::Struct(strukt) = item
419                && strukt.name.logical() == name
420            {
421                return strukt
422                    .fields
423                    .iter()
424                    .map(|field| return field.name.logical().to_owned())
425                    .collect();
426            }
427        }
428        panic!("no struct named `{name}`");
429    }
430
431    const PREAMBLE: &str = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n";
432
433    #[test]
434    fn one_direction_keeps_the_name_and_drops_nothing_it_carries() {
435        let module = split_service_yaml(&response_only(
436            "    Account:\n      description: An account.\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n        email:\n          type: string\n",
437        ));
438        assert_eq!(names(&module), vec!["Account"]);
439        assert_eq!(fields(&module, "Account"), vec!["id", "email"]);
440        assert_eq!(doc(&module, "Account"), Some("An account.".to_owned()));
441    }
442
443    #[test]
444    fn one_direction_keeps_the_name_and_drops_what_it_cannot_carry() {
445        let module = split_service_yaml(&response_only(
446            "    Account:\n      type: object\n      properties:\n        email:\n          type: string\n        secret:\n          type: string\n          writeOnly: true\n",
447        ));
448        assert_eq!(names(&module), vec!["Account"]);
449        assert_eq!(fields(&module, "Account"), vec!["email"]);
450        assert_eq!(
451            doc(&module, "Account"),
452            Some("Only a response carries this model, so a `writeOnly` property is not part of it.".to_owned())
453        );
454    }
455
456    /// A split shape names the keyword only when it drops a property.
457    ///
458    /// A holder splits because it references a split model. It drops nothing, so
459    /// a line about a `readOnly` property would state a fault that is not there.
460    #[test]
461    fn a_split_shape_that_drops_nothing_states_the_direction_alone() {
462        let module = split_yaml(&format!(
463            "{PREAMBLE}    Account:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Envelope:\n      type: object\n      properties:\n        account:\n          $ref: '#/components/schemas/Account'\n"
464        ));
465        assert_eq!(
466            doc(&module, "AccountRequest"),
467            Some("The request shape of `Account`. A `readOnly` property is not part of it.".to_owned())
468        );
469        assert_eq!(
470            doc(&module, "AccountResponse"),
471            Some("The response shape of `Account`.".to_owned()),
472            "the response drops nothing, because no property is `writeOnly`",
473        );
474        assert_eq!(
475            doc(&module, "EnvelopeRequest"),
476            Some("The request shape of `Envelope`.".to_owned())
477        );
478        assert_eq!(
479            doc(&module, "EnvelopeResponse"),
480            Some("The response shape of `Envelope`.".to_owned())
481        );
482    }
483
484    #[test]
485    fn a_holder_that_one_direction_reaches_keeps_its_name_and_names_the_split_shape() {
486        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths:\n  /accounts:\n    post:\n      operationId: createAccount\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/Account'\n      responses:\n        '200':\n          description: ok\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Page'\ncomponents:\n  schemas:\n    Account:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n        email:\n          type: string\n    Page:\n      type: object\n      properties:\n        items:\n          type: array\n          items:\n            $ref: '#/components/schemas/Account'\n";
487        let module = split_service_yaml(yaml);
488        assert_eq!(names(&module), vec!["AccountRequest", "AccountResponse", "Page"]);
489        let Some(Item::Struct(page)) = module.items.iter().find(|item| return item.name() == "Page") else {
490            panic!("no struct named `Page`");
491        };
492        assert_eq!(page.fields[0].ty.label(), "Option<Vec<AccountResponse>>");
493    }
494
495    #[test]
496    fn a_document_with_no_mark_is_left_alone() {
497        let module = split_yaml(&format!(
498            "{PREAMBLE}    Widget:\n      type: object\n      properties:\n        name:\n          type: string\n"
499        ));
500        assert_eq!(names(&module), vec!["Widget"]);
501    }
502
503    #[test]
504    fn a_marked_property_leaves_the_shape_the_other_direction_carries() {
505        let module = split_yaml(&format!(
506            "{PREAMBLE}    Account:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n        secret:\n          type: string\n          writeOnly: true\n        email:\n          type: string\n"
507        ));
508        assert_eq!(names(&module), vec!["AccountRequest", "AccountResponse"]);
509        assert_eq!(fields(&module, "AccountRequest"), vec!["secret", "email"]);
510        assert_eq!(fields(&module, "AccountResponse"), vec!["id", "email"]);
511    }
512
513    #[test]
514    fn a_mark_on_a_referenced_schema_reaches_the_property_that_names_it() {
515        let module = split_yaml(&format!(
516            "{PREAMBLE}    Id:\n      type: string\n      readOnly: true\n    Account:\n      type: object\n      properties:\n        id:\n          $ref: '#/components/schemas/Id'\n        email:\n          type: string\n"
517        ));
518        assert_eq!(fields(&module, "AccountRequest"), vec!["email"]);
519        assert_eq!(fields(&module, "AccountResponse"), vec!["id", "email"]);
520    }
521
522    #[test]
523    fn an_all_of_member_carries_its_marks_into_the_merged_shape() {
524        let module = split_yaml(&format!(
525            "{PREAMBLE}    Timestamps:\n      type: object\n      properties:\n        createdAt:\n          type: string\n          readOnly: true\n    Account:\n      allOf:\n        - $ref: '#/components/schemas/Timestamps'\n        - type: object\n          properties:\n            email:\n              type: string\n"
526        ));
527        assert_eq!(fields(&module, "AccountRequest"), vec!["email"]);
528        assert_eq!(fields(&module, "AccountResponse"), vec!["created_at", "email"]);
529    }
530
531    #[test]
532    fn a_holder_of_a_split_model_splits_and_names_the_matching_shape() {
533        let module = split_yaml(&format!(
534            "{PREAMBLE}    Account:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Envelope:\n      type: object\n      properties:\n        account:\n          $ref: '#/components/schemas/Account'\n"
535        ));
536        assert_eq!(
537            names(&module),
538            vec![
539                "AccountRequest",
540                "AccountResponse",
541                "EnvelopeRequest",
542                "EnvelopeResponse"
543            ]
544        );
545        let request = module
546            .items
547            .iter()
548            .find(|item| return item.name() == "EnvelopeRequest")
549            .expect("request shape");
550        let Item::Struct(strukt) = request else {
551            panic!("expected a struct");
552        };
553        assert_eq!(
554            strukt.fields.first().map(|field| return field.ty.label()),
555            Some("Option<AccountRequest>".to_owned())
556        );
557    }
558
559    #[test]
560    fn both_marks_on_one_property_are_rejected() {
561        let doc: openapiv3::OpenAPI = serde_yaml::from_str(&format!(
562            "{PREAMBLE}    Account:\n      type: object\n      properties:\n        secret:\n          type: string\n          readOnly: true\n          writeOnly: true\n"
563        ))
564        .expect("parse spec");
565        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
566        let names = crate::lower::rename::type_renames(&spec, None).expect("resolve names");
567        let error = crate::lower::generate_models(&spec, &names).expect_err("both marks must be rejected");
568        assert!(
569            format!("{error}").contains("`readOnly` and `writeOnly` are both set"),
570            "unexpected message: {error}"
571        );
572    }
573
574    #[test]
575    fn a_name_that_is_not_pascal_case_still_matches_the_split_set() {
576        // A `RustType::Named` holds the schema name the document wrote, so the
577        // lookup canonicalizes it. Without that, a reference to `order-item`
578        // would miss the `OrderItem` entry and keep the unsplit name.
579        let module = split_yaml(&format!(
580            "{PREAMBLE}    order-item:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Basket:\n      type: object\n      properties:\n        item:\n          $ref: '#/components/schemas/order-item'\n"
581        ));
582        assert!(names(&module).contains(&"OrderItemRequest".to_owned()));
583        let Some(Item::Struct(basket)) = module.items.iter().find(|item| return item.name() == "BasketRequest") else {
584            panic!("no request shape for `Basket`");
585        };
586        assert_eq!(
587            basket.fields.first().map(|field| return field.ty.label()),
588            Some("Option<OrderItemRequest>".to_owned())
589        );
590    }
591
592    #[test]
593    fn access_reports_the_direction_that_carries_the_property() {
594        let cases = [
595            (Access::ReadWrite, true, true),
596            (Access::ReadOnly, false, true),
597            (Access::WriteOnly, true, false),
598        ];
599        for (access, request, response) in cases {
600            assert_eq!(access.carried_by(Direction::Request), request, "{access:?} request");
601            assert_eq!(access.carried_by(Direction::Response), response, "{access:?} response");
602        }
603    }
604
605    #[test]
606    fn a_field_with_no_mark_keeps_the_default_access() {
607        let field = Field {
608            name: to_ident("name", Case::Snake),
609            rename: None,
610            doc: None,
611            deprecated: None,
612            ty: RustType::String,
613            required: true,
614            omit_empty: None,
615            serde_skip: false,
616            default: None,
617            constraints: None,
618            access: Access::default(),
619        };
620        assert_eq!(field.access, Access::ReadWrite);
621    }
622}