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::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
234fn 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
268fn project_type(ty: &RustType, direction: Direction, projections: &BTreeMap<String, Projection>) -> RustType {
271 return match ty {
272 RustType::Named(name) => {
273 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
293fn 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(¶m.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(¶m.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(¶m.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
344fn 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 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 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 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 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 fn names(module: &Module) -> Vec<String> {
412 return module.items.iter().map(|item| return item.name().to_owned()).collect();
413 }
414
415 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 #[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 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}