1use 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
37pub const REQUEST_SUFFIX: &str = "Request";
39pub const RESPONSE_SUFFIX: &str = "Response";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum Projection {
45 Split,
48 Single(Direction),
51}
52
53pub 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
83pub 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
92fn 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
117fn 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
154fn 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
164fn 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::Union(
206 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 ),
216 other => other.clone(),
217 },
218 ..enom.clone()
219 }),
220 Item::Alias(alias) => Item::Alias(Alias {
221 name: name_of(&alias.name),
222 doc: projected_doc(&alias.doc, alias.name.logical(), direction, split, false),
223 ty: project_type(&alias.ty, direction, projections),
224 ..alias.clone()
225 }),
226 };
227}
228
229fn projected_doc(doc: &Option<String>, name: &str, direction: Direction, split: bool, dropped: bool) -> Option<String> {
240 let note = match (split, dropped, direction) {
241 (true, true, Direction::Request) => {
242 format!("The request shape of `{name}`. A `readOnly` property is not part of it.")
243 }
244 (true, true, Direction::Response) => {
245 format!("The response shape of `{name}`. A `writeOnly` property is not part of it.")
246 }
247 (true, false, Direction::Request) => format!("The request shape of `{name}`."),
248 (true, false, Direction::Response) => format!("The response shape of `{name}`."),
249 (false, false, _) => return doc.clone(),
250 (false, true, Direction::Request) => {
251 "Only a request carries this model, so a `readOnly` property is not part of it.".to_owned()
252 }
253 (false, true, Direction::Response) => {
254 "Only a response carries this model, so a `writeOnly` property is not part of it.".to_owned()
255 }
256 };
257 return Some(match doc {
258 Some(text) => format!("{text}\n\n{note}"),
259 None => note,
260 });
261}
262
263fn project_type(ty: &RustType, direction: Direction, projections: &BTreeMap<String, Projection>) -> RustType {
266 return match ty {
267 RustType::Named(name) => {
268 let canonical = to_ident(name, Case::Pascal);
272 match projections.get(canonical.logical()) {
273 Some(Projection::Split) => {
274 RustType::Named(projected_name(canonical.logical(), direction).logical().to_owned())
275 }
276 _ => ty.clone(),
277 }
278 }
279 RustType::Vec(inner) => RustType::Vec(Box::new(project_type(inner, direction, projections))),
280 RustType::Map(inner) => RustType::Map(Box::new(project_type(inner, direction, projections))),
281 RustType::Option(inner) => RustType::Option(Box::new(project_type(inner, direction, projections))),
282 RustType::Boxed(inner) => RustType::Boxed(Box::new(project_type(inner, direction, projections))),
283 other => other.clone(),
284 };
285}
286
287fn project_service(service: &mut Service, projections: &BTreeMap<String, Projection>) {
289 for operation in &mut service.operations {
290 for param in &mut operation.path_params {
291 param.ty = project_type(¶m.ty, Direction::Request, projections);
292 }
293 if let Some(query) = &mut operation.query {
294 project_struct(query, Direction::Request, projections);
295 }
296 if let Some(headers) = &mut operation.headers {
297 for param in &mut headers.params {
298 param.ty = project_type(¶m.ty, Direction::Request, projections);
299 }
300 }
301 if let Some(cookies) = &mut operation.cookies {
302 for param in &mut cookies.params {
303 param.ty = project_type(¶m.ty, Direction::Request, projections);
304 }
305 }
306 if let Some(request) = &mut operation.request {
307 match request {
308 RequestPayload::Single(body) => body.ty = project_type(&body.ty, Direction::Request, projections),
309 RequestPayload::Multipart(multipart) => {
310 for field in &mut multipart.fields {
311 field.ty = project_type(&field.ty, Direction::Request, projections);
312 }
313 }
314 RequestPayload::Negotiated(negotiated) => {
315 for variant in &mut negotiated.variants {
316 variant.body.ty = project_type(&variant.body.ty, Direction::Request, projections);
317 }
318 }
319 }
320 }
321 for case in &mut operation.responses {
322 for header in &mut case.headers {
323 header.ty = project_type(&header.ty, Direction::Response, projections);
324 }
325 match &mut case.body {
326 Some(ResponseBody::Single(body)) => body.ty = project_type(&body.ty, Direction::Response, projections),
327 Some(ResponseBody::Negotiated(negotiated)) => {
328 for variant in &mut negotiated.variants {
329 variant.body.ty = project_type(&variant.body.ty, Direction::Response, projections);
330 }
331 }
332 None => {}
333 }
334 }
335 }
336}
337
338fn project_struct(strukt: &mut Struct, direction: Direction, projections: &BTreeMap<String, Projection>) {
340 for field in &mut strukt.fields {
341 field.ty = project_type(&field.ty, direction, projections);
342 }
343 if let Some(additional) = &mut strukt.additional_properties {
344 *additional = project_type(additional, direction, projections);
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use std::path::PathBuf;
351
352 use super::*;
353 use crate::ir::Access;
354 use crate::ir::Field;
355 use crate::loader::Spec;
356
357 fn split_yaml(yaml: &str) -> Module {
360 let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
361 let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
362 let names = crate::lower::rename::type_renames(&spec, None).expect("resolve names");
363 let mut module = crate::lower::generate_models(&spec, &names).expect("lower schemas");
364 split_by_direction(&mut module, None);
365 return module;
366 }
367
368 fn split_service_yaml(yaml: &str) -> Module {
371 let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
372 let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
373 let names = crate::lower::rename::type_renames(&spec, None).expect("resolve names");
374 let mut module = crate::lower::generate_models(&spec, &names).expect("lower schemas");
375 let mut service =
376 crate::lower::generate_service(&spec, &Default::default(), "Response").expect("lower operations");
377 crate::lower::rewrite_service(&mut service, names.renames());
378 split_by_direction(&mut module, Some(&mut service));
379 return module;
380 }
381
382 fn doc(module: &Module, name: &str) -> Option<String> {
384 for item in &module.items {
385 if item.name() == name {
386 return match item {
387 Item::Struct(strukt) => strukt.doc.clone(),
388 Item::Enum(enom) => enom.doc.clone(),
389 Item::Alias(alias) => alias.doc.clone(),
390 };
391 }
392 }
393 panic!("no item named `{name}`");
394 }
395
396 fn response_only(schemas: &str) -> String {
399 return format!(
400 "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}"
401 );
402 }
403
404 fn names(module: &Module) -> Vec<String> {
406 return module.items.iter().map(|item| return item.name().to_owned()).collect();
407 }
408
409 fn fields(module: &Module, name: &str) -> Vec<String> {
411 for item in &module.items {
412 if let Item::Struct(strukt) = item
413 && strukt.name.logical() == name
414 {
415 return strukt
416 .fields
417 .iter()
418 .map(|field| return field.name.logical().to_owned())
419 .collect();
420 }
421 }
422 panic!("no struct named `{name}`");
423 }
424
425 const PREAMBLE: &str = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n";
426
427 #[test]
428 fn one_direction_keeps_the_name_and_drops_nothing_it_carries() {
429 let module = split_service_yaml(&response_only(
430 " Account:\n description: An account.\n type: object\n properties:\n id:\n type: string\n readOnly: true\n email:\n type: string\n",
431 ));
432 assert_eq!(names(&module), vec!["Account"]);
433 assert_eq!(fields(&module, "Account"), vec!["id", "email"]);
434 assert_eq!(doc(&module, "Account"), Some("An account.".to_owned()));
435 }
436
437 #[test]
438 fn one_direction_keeps_the_name_and_drops_what_it_cannot_carry() {
439 let module = split_service_yaml(&response_only(
440 " Account:\n type: object\n properties:\n email:\n type: string\n secret:\n type: string\n writeOnly: true\n",
441 ));
442 assert_eq!(names(&module), vec!["Account"]);
443 assert_eq!(fields(&module, "Account"), vec!["email"]);
444 assert_eq!(
445 doc(&module, "Account"),
446 Some("Only a response carries this model, so a `writeOnly` property is not part of it.".to_owned())
447 );
448 }
449
450 #[test]
455 fn a_split_shape_that_drops_nothing_states_the_direction_alone() {
456 let module = split_yaml(&format!(
457 "{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"
458 ));
459 assert_eq!(
460 doc(&module, "AccountRequest"),
461 Some("The request shape of `Account`. A `readOnly` property is not part of it.".to_owned())
462 );
463 assert_eq!(
464 doc(&module, "AccountResponse"),
465 Some("The response shape of `Account`.".to_owned()),
466 "the response drops nothing, because no property is `writeOnly`",
467 );
468 assert_eq!(
469 doc(&module, "EnvelopeRequest"),
470 Some("The request shape of `Envelope`.".to_owned())
471 );
472 assert_eq!(
473 doc(&module, "EnvelopeResponse"),
474 Some("The response shape of `Envelope`.".to_owned())
475 );
476 }
477
478 #[test]
479 fn a_holder_that_one_direction_reaches_keeps_its_name_and_names_the_split_shape() {
480 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";
481 let module = split_service_yaml(yaml);
482 assert_eq!(names(&module), vec!["AccountRequest", "AccountResponse", "Page"]);
483 let Some(Item::Struct(page)) = module.items.iter().find(|item| return item.name() == "Page") else {
484 panic!("no struct named `Page`");
485 };
486 assert_eq!(page.fields[0].ty.label(), "Option<Vec<AccountResponse>>");
487 }
488
489 #[test]
490 fn a_document_with_no_mark_is_left_alone() {
491 let module = split_yaml(&format!(
492 "{PREAMBLE} Widget:\n type: object\n properties:\n name:\n type: string\n"
493 ));
494 assert_eq!(names(&module), vec!["Widget"]);
495 }
496
497 #[test]
498 fn a_marked_property_leaves_the_shape_the_other_direction_carries() {
499 let module = split_yaml(&format!(
500 "{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"
501 ));
502 assert_eq!(names(&module), vec!["AccountRequest", "AccountResponse"]);
503 assert_eq!(fields(&module, "AccountRequest"), vec!["secret", "email"]);
504 assert_eq!(fields(&module, "AccountResponse"), vec!["id", "email"]);
505 }
506
507 #[test]
508 fn a_mark_on_a_referenced_schema_reaches_the_property_that_names_it() {
509 let module = split_yaml(&format!(
510 "{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"
511 ));
512 assert_eq!(fields(&module, "AccountRequest"), vec!["email"]);
513 assert_eq!(fields(&module, "AccountResponse"), vec!["id", "email"]);
514 }
515
516 #[test]
517 fn an_all_of_member_carries_its_marks_into_the_merged_shape() {
518 let module = split_yaml(&format!(
519 "{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"
520 ));
521 assert_eq!(fields(&module, "AccountRequest"), vec!["email"]);
522 assert_eq!(fields(&module, "AccountResponse"), vec!["created_at", "email"]);
523 }
524
525 #[test]
526 fn a_holder_of_a_split_model_splits_and_names_the_matching_shape() {
527 let module = split_yaml(&format!(
528 "{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"
529 ));
530 assert_eq!(
531 names(&module),
532 vec![
533 "AccountRequest",
534 "AccountResponse",
535 "EnvelopeRequest",
536 "EnvelopeResponse"
537 ]
538 );
539 let request = module
540 .items
541 .iter()
542 .find(|item| return item.name() == "EnvelopeRequest")
543 .expect("request shape");
544 let Item::Struct(strukt) = request else {
545 panic!("expected a struct");
546 };
547 assert_eq!(
548 strukt.fields.first().map(|field| return field.ty.label()),
549 Some("Option<AccountRequest>".to_owned())
550 );
551 }
552
553 #[test]
554 fn both_marks_on_one_property_are_rejected() {
555 let doc: openapiv3::OpenAPI = serde_yaml::from_str(&format!(
556 "{PREAMBLE} Account:\n type: object\n properties:\n secret:\n type: string\n readOnly: true\n writeOnly: true\n"
557 ))
558 .expect("parse spec");
559 let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
560 let names = crate::lower::rename::type_renames(&spec, None).expect("resolve names");
561 let error = crate::lower::generate_models(&spec, &names).expect_err("both marks must be rejected");
562 assert!(
563 format!("{error}").contains("`readOnly` and `writeOnly` are both set"),
564 "unexpected message: {error}"
565 );
566 }
567
568 #[test]
569 fn a_name_that_is_not_pascal_case_still_matches_the_split_set() {
570 let module = split_yaml(&format!(
574 "{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"
575 ));
576 assert!(names(&module).contains(&"OrderItemRequest".to_owned()));
577 let Some(Item::Struct(basket)) = module.items.iter().find(|item| return item.name() == "BasketRequest") else {
578 panic!("no request shape for `Basket`");
579 };
580 assert_eq!(
581 basket.fields.first().map(|field| return field.ty.label()),
582 Some("Option<OrderItemRequest>".to_owned())
583 );
584 }
585
586 #[test]
587 fn access_reports_the_direction_that_carries_the_property() {
588 let cases = [
589 (Access::ReadWrite, true, true),
590 (Access::ReadOnly, false, true),
591 (Access::WriteOnly, true, false),
592 ];
593 for (access, request, response) in cases {
594 assert_eq!(access.carried_by(Direction::Request), request, "{access:?} request");
595 assert_eq!(access.carried_by(Direction::Response), response, "{access:?} response");
596 }
597 }
598
599 #[test]
600 fn a_field_with_no_mark_keeps_the_default_access() {
601 let field = Field {
602 name: to_ident("name", Case::Snake),
603 rename: None,
604 doc: None,
605 deprecated: None,
606 ty: RustType::String,
607 required: true,
608 omit_empty: None,
609 serde_skip: false,
610 default: None,
611 constraints: None,
612 access: Access::default(),
613 };
614 assert_eq!(field.access, Access::ReadWrite);
615 }
616}