1use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13use serde_json::{Map, Value};
14
15use crate::adapter::{AdaptedApi, Adapter};
16use crate::ast::{
17 AdditionalProperties, Discriminator, OAuthFlow, OperationMediaType, OperationParameter,
18 OperationRequestBody, OperationResponse, SchemaKind, SchemaValue, SecurityRequirement,
19 SecurityScheme, SecuritySchemeCatalog, SecuritySchemeKind,
20};
21use crate::{Api, Field, HttpMethod, Operation, Schema};
22
23#[derive(Clone, Debug)]
30pub struct OpenApiSidecar {
31 output_dir: PathBuf,
32 name: String,
33 version: String,
34}
35
36impl OpenApiSidecar {
37 pub fn new(
38 output_dir: impl Into<PathBuf>,
39 name: impl Into<String>,
40 version: impl Into<String>,
41 ) -> Self {
42 Self {
43 output_dir: output_dir.into(),
44 name: name.into(),
45 version: version.into(),
46 }
47 }
48
49 pub fn load(&self) -> Result<AdaptedApi> {
51 Ok(AdaptedApi::new(
52 load_operations(&self.output_dir, self.name.clone(), self.version.clone())?,
53 load_security_schemes(&self.output_dir)?,
54 ))
55 }
56}
57
58impl Adapter for OpenApiSidecar {
59 fn adapt(&self) -> Result<AdaptedApi> {
60 self.load()
61 }
62}
63
64#[derive(Debug, Deserialize)]
65struct SidecarOperation {
66 #[serde(default)]
67 kind: String,
68 path: String,
69 method: String,
70 #[serde(default)]
71 operation_id: String,
72 #[serde(default)]
73 summary: Option<String>,
74 #[serde(default)]
75 description: Option<String>,
76 #[serde(default)]
77 deprecated: bool,
78 #[serde(default)]
79 extensions: BTreeMap<String, Value>,
80 #[serde(default)]
81 parameters: Vec<SidecarParameter>,
82 #[serde(default)]
83 request_body: Option<SidecarBody>,
84 #[serde(default)]
85 responses: Vec<SidecarResponse>,
86 #[serde(default)]
87 request_examples: Vec<SidecarExample>,
88 #[serde(default)]
89 servers: Vec<Value>,
90 #[serde(default)]
91 tags: Vec<String>,
92 #[serde(default)]
93 security_requirements: Vec<SidecarSecurityRequirement>,
94}
95
96#[derive(Debug, Deserialize)]
97struct SidecarBody {
98 #[serde(default)]
99 required: bool,
100 #[serde(default)]
101 description: Option<String>,
102 media_types: Vec<SidecarMediaType>,
103}
104
105#[derive(Debug, Deserialize)]
106struct SidecarResponse {
107 #[serde(flatten)]
108 content_metadata: crate::openapi32::ContentDefinition,
109 code: String,
110 #[serde(default)]
111 example_json: Option<String>,
112 #[serde(default)]
113 description: Option<String>,
114 #[serde(default)]
115 content_type: Option<String>,
116 #[serde(default)]
117 schema_definition: Option<Value>,
118}
119
120#[derive(Debug, Deserialize)]
121struct SidecarParameter {
122 #[serde(default)]
123 content: Vec<crate::openapi32::ContentDefinition>,
124 name: String,
125 #[serde(default)]
126 allow_reserved: Option<bool>,
127 #[serde(default)]
128 example: Option<Value>,
129 #[serde(rename = "in")]
130 location: String,
131 #[serde(default)]
132 required: bool,
133 #[serde(default)]
134 description: Option<String>,
135 #[serde(default)]
136 style: Option<String>,
137 #[serde(default)]
138 explode: Option<bool>,
139 #[serde(default)]
140 schema: Option<Value>,
141}
142
143type SidecarMediaType = crate::openapi32::ContentDefinition;
144
145#[derive(Debug, Deserialize, serde::Serialize)]
148struct SidecarExample {
149 label: String,
150 #[serde(default)]
151 content_type: Option<String>,
152 #[serde(default)]
153 example_json: String,
154}
155
156#[derive(Debug, Deserialize)]
157struct SidecarSecurityRequirement {
158 #[serde(default)]
159 schemes: BTreeMap<String, Option<Vec<String>>>,
163}
164
165#[derive(Debug, Deserialize)]
166struct SidecarSchemas {
167 #[serde(default)]
168 schemas: Vec<SidecarSchema>,
169}
170
171#[derive(Debug, Deserialize)]
172struct SidecarSchema {
173 name: String,
174 schema: Value,
175}
176
177#[derive(Debug, Deserialize)]
178struct SidecarSecuritySchemes {
179 #[serde(default)]
180 schemes: Vec<SidecarSecurityScheme>,
181}
182
183#[derive(Debug, Deserialize)]
184struct SidecarSecurityScheme {
185 name: String,
186 #[serde(rename = "type")]
187 type_name: String,
188 #[serde(default)]
189 description: Option<String>,
190 #[serde(default)]
191 api_key_name: Option<String>,
192 #[serde(default)]
193 api_key_in: Option<String>,
194 #[serde(default)]
195 http_scheme: Option<String>,
196 #[serde(default)]
197 bearer_format: Option<String>,
198 #[serde(default)]
199 oauth_flows: Vec<SidecarOAuthFlow>,
200 #[serde(default)]
201 open_id_connect_url: Option<String>,
202 #[serde(default)]
203 oauth2_metadata_url: Option<String>,
204}
205
206#[derive(Debug, Deserialize)]
207struct SidecarOAuthFlow {
208 #[serde(default)]
209 device_authorization_url: Option<String>,
210 #[serde(rename = "type")]
211 flow_type: String,
212 #[serde(default)]
213 authorization_url: Option<String>,
214 #[serde(default)]
215 token_url: Option<String>,
216 #[serde(default)]
217 refresh_url: Option<String>,
218 #[serde(default)]
219 scopes: BTreeMap<String, String>,
220}
221
222pub fn load_operations(output_dir: &Path, name: String, version: String) -> Result<Api> {
226 let index: BTreeMap<String, String> = read_json(&output_dir.join("operations.json"))?;
227 let order: Vec<String> = read_json(&output_dir.join("operations-order.json"))?;
228 let mut operations = Vec::with_capacity(order.len());
229
230 for key in order {
231 let file = index
232 .get(&key)
233 .with_context(|| format!("sidecar operation index is missing {key:?}"))?;
234 let document: SidecarOperation = read_json(&output_dir.join("operations").join(file))?;
235 let mut annotations = document.extensions;
236 if !document.kind.is_empty() {
237 annotations.insert(
238 "poolster.openapi.operation_kind".into(),
239 Value::String(document.kind),
240 );
241 }
242 add_optional_annotation(&mut annotations, "summary", document.summary);
243 add_optional_annotation(&mut annotations, "description", document.description);
244 if !document.servers.is_empty() {
245 annotations.insert(
246 "poolster.openapi.servers".into(),
247 serde_json::to_value(&document.servers)?,
248 );
249 }
250 if !document.tags.is_empty() {
251 annotations.insert("tags".into(), serde_json::to_value(&document.tags)?);
252 }
253 let response_examples = document.responses.iter().filter_map(|response| response.example_json.as_ref().map(|example| serde_json::json!({"status":response.code, "content_type":response.content_type, "example_json":example, "label":response.description}))).collect::<Vec<_>>();
254 if !response_examples.is_empty() {
255 annotations.insert(
256 "poolster.docs.response_examples".into(),
257 serde_json::to_value(response_examples)?,
258 );
259 }
260 if document.deprecated {
261 annotations.insert("deprecated".into(), Value::Bool(true));
262 }
263 if !document.request_examples.is_empty() {
264 annotations.insert(
266 "poolster.docs.request_examples".into(),
267 serde_json::to_value(&document.request_examples)
268 .expect("sidecar request examples are JSON-compatible"),
269 );
270 }
271 if let Some(body) = &document.request_body {
272 let encodings = body
273 .media_types
274 .iter()
275 .filter(|media| !media.encoding.is_empty())
276 .map(|media| (media.content_type.clone(), &media.encoding))
277 .collect::<BTreeMap<_, _>>();
278 if !encodings.is_empty() {
279 annotations.insert(
280 "poolster.request_body_encodings".into(),
281 serde_json::to_value(encodings)
282 .expect("sidecar form encodings are JSON-compatible"),
283 );
284 }
285 }
286 if let Some(body) = &document.request_body {
287 annotations.insert(
288 "poolster.request_content".into(),
289 serde_json::to_value(&body.media_types)?,
290 );
291 }
292 let responses = document
293 .responses
294 .iter()
295 .filter(|response| response.content_type.is_some())
296 .map(|response| crate::openapi32::ResponseContentDefinition {
297 status: response.code.clone(),
298 content: {
299 let mut content = response.content_metadata.clone();
300 content.content_type = response.content_type.clone().unwrap_or_default();
301 content.schema_definition = response.schema_definition.clone();
302 content
303 },
304 })
305 .collect::<Vec<_>>();
306 if !responses.is_empty() {
307 annotations.insert(
308 "poolster.response_content".into(),
309 serde_json::to_value(responses)?,
310 );
311 }
312 operations.push(Operation {
313 id: if document.operation_id.is_empty() {
314 operation_id(&document.method, &document.path)
315 } else {
316 document.operation_id
317 },
318 method: parse_method(&document.method)?,
319 path: document.path,
320 parameters: document
321 .parameters
322 .into_iter()
323 .map(|parameter| {
324 let mut annotations = BTreeMap::new();
325 add_optional_annotation(&mut annotations, "style", parameter.style);
326 if let Some(explode) = parameter.explode {
327 annotations.insert("explode".into(), Value::Bool(explode));
328 }
329 if let Some(value) = parameter.allow_reserved {
330 annotations.insert("allowReserved".into(), Value::Bool(value));
331 }
332 if let Some(value) = parameter.example {
333 annotations.insert("example".into(), value);
334 }
335 if !parameter.content.is_empty() {
336 annotations.insert(
337 "poolster.parameter_content".into(),
338 serde_json::to_value(¶meter.content)
339 .expect("typed content is JSON compatible"),
340 );
341 }
342 OperationParameter {
343 name: parameter.name,
344 location: parameter.location,
345 required: parameter.required,
346 schema: parameter.schema.as_ref().map(convert_value).or_else(|| {
347 parameter
348 .content
349 .first()
350 .and_then(crate::openapi32::ContentDefinition::schema)
351 }),
352 description: parameter.description,
353 annotations,
354 }
355 })
356 .collect(),
357 request_body: document.request_body.as_ref().map(convert_request_body),
358 responses: convert_responses(&document.responses),
359 security: document
360 .security_requirements
361 .into_iter()
362 .map(|requirement| SecurityRequirement {
363 schemes: requirement
364 .schemes
365 .into_iter()
366 .map(|(name, scopes)| (name, scopes.unwrap_or_default()))
367 .collect(),
368 })
369 .collect(),
370 annotations,
371 });
372 }
373
374 let document: SidecarSchemas = read_json(&output_dir.join("schemas.json"))?;
375 let schemas = document
376 .schemas
377 .iter()
378 .map(|schema| Schema::new(schema.name.clone(), convert_value(&schema.schema)))
379 .collect();
380
381 let mut annotations = BTreeMap::new();
382 let metadata_path = output_dir.join("api-metadata.json");
383 if metadata_path.exists() {
384 let metadata: crate::openapi32::ApiMetadata = read_json(&metadata_path)?;
385 annotations.insert(
386 "poolster.openapi.metadata".into(),
387 serde_json::to_value(metadata)?,
388 );
389 }
390 let mut api = Api {
391 name,
392 version,
393 schemas,
394 operations,
395 annotations,
396 };
397 let root_path = output_dir.join("vendor-extensions.json");
398 let root: Value = if root_path.exists() {
399 read_json(&root_path)?
400 } else {
401 Value::Null
402 };
403 disambiguate_source_operation_ids(&mut api.operations)?;
404 let report = crate::vendor::normalize_api(&mut api, &root);
405 let mut operation_ids = std::collections::BTreeSet::new();
406 for operation in &api.operations {
407 anyhow::ensure!(
408 operation_ids.insert(&operation.id),
409 "duplicate SDK operation name {:?} after vendor normalization; choose unique method names",
410 operation.id
411 );
412 }
413 api.annotations.insert(
414 "poolster.vendor.report".into(),
415 serde_json::to_value(report)?,
416 );
417 Ok(api)
418}
419
420fn disambiguate_source_operation_ids(operations: &mut [Operation]) -> Result<()> {
423 let mut counts = BTreeMap::new();
424 for operation in operations.iter() {
425 *counts.entry(operation.id.clone()).or_insert(0usize) += 1;
426 }
427 let mut used: std::collections::BTreeSet<String> = operations
428 .iter()
429 .map(|operation| operation.id.clone())
430 .collect();
431 let mut identities = std::collections::BTreeSet::new();
432 for operation in operations {
433 let kind = operation
434 .annotations
435 .get("poolster.openapi.operation_kind")
436 .and_then(Value::as_str)
437 .unwrap_or("operation");
438 let identity = format!("{kind}: {} {}", operation.method.as_str(), operation.path);
439 anyhow::ensure!(
440 identities.insert(identity.clone()),
441 "duplicate wire operation {identity}"
442 );
443 if counts[&operation.id] > 1 {
444 let hash = identity.bytes().fold(0xcbf29ce484222325u64, |hash, byte| {
445 (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
446 });
447 let original = operation.id.clone();
448 let candidate = format!("{original}_{hash:016x}");
449 anyhow::ensure!(
450 used.insert(candidate.clone()),
451 "operation identifier collision after allocating {candidate}"
452 );
453 operation.annotations.insert(
454 "poolster.openapi.original_operation_id".into(),
455 serde_json::json!(original),
456 );
457 operation.id = candidate;
458 }
459 }
460 Ok(())
461}
462
463pub fn load_security_schemes(output_dir: &Path) -> Result<SecuritySchemeCatalog> {
467 let document: SidecarSecuritySchemes = read_json(&output_dir.join("security-schemes.json"))?;
468 Ok(SecuritySchemeCatalog {
469 schemes: document
470 .schemes
471 .into_iter()
472 .map(convert_security_scheme)
473 .collect(),
474 })
475}
476
477fn convert_security_scheme(scheme: SidecarSecurityScheme) -> SecurityScheme {
478 let kind = match scheme.type_name.as_str() {
479 "apiKey" => SecuritySchemeKind::ApiKey {
480 name: scheme.api_key_name,
481 location: scheme.api_key_in,
482 },
483 "http" => SecuritySchemeKind::Http {
484 scheme: scheme.http_scheme,
485 bearer_format: scheme.bearer_format,
486 },
487 "oauth2" => SecuritySchemeKind::OAuth2 {
488 flows: scheme
489 .oauth_flows
490 .into_iter()
491 .map(|flow| OAuthFlow {
492 flow_type: flow.flow_type,
493 device_authorization_url: flow.device_authorization_url,
494 authorization_url: flow.authorization_url,
495 token_url: flow.token_url,
496 refresh_url: flow.refresh_url,
497 scopes: flow.scopes,
498 })
499 .collect(),
500 metadata_url: scheme.oauth2_metadata_url,
501 },
502 "openIdConnect" => SecuritySchemeKind::OpenIdConnect {
503 discovery_url: scheme.open_id_connect_url,
504 },
505 type_name => SecuritySchemeKind::Other {
506 type_name: type_name.to_owned(),
507 },
508 };
509 SecurityScheme {
510 name: scheme.name,
511 description: scheme.description,
512 kind,
513 }
514}
515
516fn convert_request_body(body: &SidecarBody) -> OperationRequestBody {
517 let media_types = body
518 .media_types
519 .iter()
520 .map(convert_media_type)
521 .collect::<Vec<_>>();
522
523 OperationRequestBody {
524 required: body.required,
525 description: body.description.clone(),
526 media_types,
527 }
528}
529
530fn convert_media_type(media_type: &SidecarMediaType) -> OperationMediaType {
531 OperationMediaType {
532 content_type: media_type.content_type.clone(),
533 schema: content_schema(media_type),
534 }
535}
536
537fn item_array(content: &crate::openapi32::ContentDefinition) -> Option<SchemaValue> {
538 content.item_schema().map(|item| {
539 SchemaValue::new(SchemaKind::Array {
540 items: Box::new(item),
541 })
542 })
543}
544fn content_schema(content: &crate::openapi32::ContentDefinition) -> Option<SchemaValue> {
545 if is_event_stream_media(&content.content_type) && content.item_schema_definition.is_some() {
546 return content.item_schema();
547 }
548 if is_sequential_json(&content.content_type) && content.item_schema_definition.is_some() {
549 return item_array(content);
550 }
551 content.schema().or_else(|| item_array(content))
552}
553
554fn is_sequential_json(content_type: &str) -> bool {
555 matches!(
556 content_type
557 .split(';')
558 .next()
559 .unwrap_or("")
560 .trim()
561 .to_ascii_lowercase()
562 .as_str(),
563 "application/json-seq"
564 | "application/x-ndjson"
565 | "application/ndjson"
566 | "application/jsonl"
567 )
568}
569
570fn is_event_stream_media(content_type: &str) -> bool {
571 content_type
572 .split(';')
573 .next()
574 .unwrap_or("")
575 .trim()
576 .eq_ignore_ascii_case("text/event-stream")
577}
578
579fn response_schema(response: &SidecarResponse) -> Option<SchemaValue> {
580 if response
581 .content_type
582 .as_deref()
583 .is_some_and(|media| media.trim().to_ascii_lowercase().starts_with("multipart/"))
584 {
585 let mut value = SchemaValue::new(SchemaKind::String);
586 value.format = Some("binary".into());
587 return Some(value);
588 }
589 if response
590 .content_type
591 .as_deref()
592 .is_some_and(is_event_stream_media)
593 && response.content_metadata.item_schema_definition.is_some()
594 {
595 return response.content_metadata.item_schema();
596 }
597 if response
598 .content_type
599 .as_deref()
600 .is_some_and(is_sequential_json)
601 && response.content_metadata.item_schema_definition.is_some()
602 {
603 return item_array(&response.content_metadata);
604 }
605 response
606 .schema_definition
607 .as_ref()
608 .map(convert_value)
609 .or_else(|| item_array(&response.content_metadata))
610}
611
612fn convert_responses(responses: &[SidecarResponse]) -> Vec<OperationResponse> {
613 let mut converted = Vec::<OperationResponse>::new();
617 for response in responses {
618 if let Some(existing) = converted
619 .iter_mut()
620 .find(|existing| existing.status == response.code)
621 {
622 if let Some(content_type) = &response.content_type {
623 existing.media_types.push(OperationMediaType {
624 content_type: content_type.clone(),
625 schema: response_schema(response),
626 });
627 }
628 continue;
629 }
630
631 converted.push(OperationResponse {
632 status: response.code.clone(),
633 description: response.description.clone(),
634 media_types: response
635 .content_type
636 .as_ref()
637 .map(|content_type| {
638 vec![OperationMediaType {
639 content_type: content_type.clone(),
640 schema: response_schema(response),
641 }]
642 })
643 .unwrap_or_default(),
644 });
645 }
646 converted
647}
648
649fn add_optional_annotation(
650 annotations: &mut BTreeMap<String, Value>,
651 key: &str,
652 value: Option<String>,
653) {
654 if let Some(value) = value {
655 annotations.insert(key.to_owned(), Value::String(value));
656 }
657}
658
659pub(crate) fn convert_value(schema: &Value) -> SchemaValue {
662 let Some(object) = schema.as_object() else {
663 return SchemaValue::unknown();
664 };
665 let mut value = SchemaValue::new(convert_kind(object));
666 value.nullable = object
667 .get("nullable")
668 .and_then(Value::as_bool)
669 .unwrap_or(false)
670 || object
671 .get("type")
672 .and_then(Value::as_array)
673 .is_some_and(|types| types.iter().any(|kind| kind.as_str() == Some("null")));
674 value.format = object
675 .get("format")
676 .and_then(Value::as_str)
677 .map(str::to_owned);
678 value.enum_values = object
679 .get("enum")
680 .and_then(Value::as_array)
681 .cloned()
682 .unwrap_or_default();
683 value.const_value = object.get("const").cloned();
684 value.default = object.get("default").cloned();
685 value.title = object
686 .get("title")
687 .and_then(Value::as_str)
688 .map(str::to_owned);
689 value.description = object
690 .get("description")
691 .and_then(Value::as_str)
692 .map(str::to_owned);
693 value.deprecated = object
694 .get("deprecated")
695 .and_then(Value::as_bool)
696 .unwrap_or(false);
697 value.read_only = object
698 .get("readOnly")
699 .and_then(Value::as_bool)
700 .unwrap_or(false);
701 value.write_only = object
702 .get("writeOnly")
703 .and_then(Value::as_bool)
704 .unwrap_or(false);
705 value.discriminator = object.get("discriminator").and_then(convert_discriminator);
706 for (key, raw) in object {
707 if key.starts_with("x-") {
708 value.extensions.insert(key.clone(), raw.clone());
709 } else if is_constraint_key(key) {
710 value.constraints.insert(key.clone(), raw.clone());
711 }
712 }
713 value
714}
715
716fn convert_kind(schema: &Map<String, Value>) -> SchemaKind {
717 if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
718 return SchemaKind::Reference {
719 reference: reference.to_owned(),
720 };
721 }
722 if let Some(variants) = schema.get("oneOf").and_then(Value::as_array) {
723 return SchemaKind::OneOf {
724 variants: variants.iter().map(convert_value).collect(),
725 };
726 }
727 if let Some(variants) = schema.get("anyOf").and_then(Value::as_array) {
728 return SchemaKind::AnyOf {
729 variants: variants.iter().map(convert_value).collect(),
730 };
731 }
732 if let Some(variants) = schema.get("allOf").and_then(Value::as_array) {
733 return SchemaKind::AllOf {
734 variants: variants.iter().map(convert_value).collect(),
735 };
736 }
737 if let Some(negated) = schema.get("not") {
738 return SchemaKind::Not {
739 schema: Box::new(convert_value(negated)),
740 };
741 }
742 if let Some(types) = schema.get("type").and_then(Value::as_array) {
743 let variants = types
744 .iter()
745 .filter_map(Value::as_str)
746 .filter(|kind| *kind != "null")
747 .map(|kind| SchemaValue::new(kind_from_type(kind, schema)))
748 .collect::<Vec<_>>();
749 if variants.len() > 1 {
750 return SchemaKind::AnyOf { variants };
751 }
752 return variants
753 .into_iter()
754 .next()
755 .map(|value| value.kind)
756 .unwrap_or(SchemaKind::Null);
757 }
758 schema
759 .get("type")
760 .and_then(Value::as_str)
761 .map(|kind| kind_from_type(kind, schema))
762 .unwrap_or_else(|| {
763 if schema.contains_key("properties") || schema.contains_key("additionalProperties") {
764 kind_from_type("object", schema)
765 } else if schema.contains_key("items") {
766 kind_from_type("array", schema)
767 } else {
768 SchemaKind::Any
769 }
770 })
771}
772
773fn kind_from_type(kind: &str, schema: &Map<String, Value>) -> SchemaKind {
774 match kind {
775 "null" => SchemaKind::Null,
776 "boolean" => SchemaKind::Boolean,
777 "integer" => SchemaKind::Integer,
778 "number" => SchemaKind::Number,
779 "string" => SchemaKind::String,
780 "array" => SchemaKind::Array {
781 items: Box::new(
782 schema
783 .get("items")
784 .map(convert_value)
785 .unwrap_or_else(SchemaValue::unknown),
786 ),
787 },
788 "object" => SchemaKind::Object {
789 fields: object_fields(schema),
790 additional_properties: additional_properties(schema.get("additionalProperties")),
791 },
792 _ => SchemaKind::Any,
793 }
794}
795
796fn object_fields(schema: &Map<String, Value>) -> Vec<Field> {
797 let required = schema
798 .get("required")
799 .and_then(Value::as_array)
800 .into_iter()
801 .flatten()
802 .filter_map(Value::as_str)
803 .collect::<BTreeSet<_>>();
804 schema
805 .get("properties")
806 .and_then(Value::as_object)
807 .into_iter()
808 .flatten()
809 .map(|(name, value)| Field {
810 name: name.clone(),
811 value: convert_value(value),
812 required: required.contains(name.as_str()),
813 annotations: BTreeMap::new(),
814 })
815 .collect()
816}
817
818fn additional_properties(value: Option<&Value>) -> AdditionalProperties {
819 match value {
820 None => AdditionalProperties::Unspecified,
821 Some(Value::Bool(true)) => AdditionalProperties::Any,
822 Some(Value::Bool(false)) => AdditionalProperties::Forbidden,
823 Some(value) => AdditionalProperties::Schema {
824 value: Box::new(convert_value(value)),
825 },
826 }
827}
828
829fn convert_discriminator(value: &Value) -> Option<Discriminator> {
830 let object = value.as_object()?;
831 Some(Discriminator {
832 property_name: object.get("propertyName")?.as_str()?.to_owned(),
833 mapping: object
834 .get("mapping")
835 .and_then(Value::as_object)
836 .map(|mapping| {
837 mapping
838 .iter()
839 .filter_map(|(key, value)| {
840 value.as_str().map(|value| (key.clone(), value.to_owned()))
841 })
842 .collect()
843 })
844 .unwrap_or_default(),
845 })
846}
847
848fn is_constraint_key(key: &str) -> bool {
849 matches!(
850 key,
851 "multipleOf"
852 | "maximum"
853 | "exclusiveMaximum"
854 | "minimum"
855 | "exclusiveMinimum"
856 | "maxLength"
857 | "minLength"
858 | "pattern"
859 | "maxItems"
860 | "minItems"
861 | "uniqueItems"
862 | "maxProperties"
863 | "minProperties"
864 | "contentEncoding"
865 | "contentMediaType"
866 | "example"
867 | "examples"
868 | "$schema"
869 | "$id"
870 | "$anchor"
871 | "$comment"
872 | "unevaluatedProperties"
873 )
874}
875
876fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
877 let content = fs::read_to_string(path)
878 .with_context(|| format!("read Go OpenAPI sidecar output {}", path.display()))?;
879 serde_json::from_str(&content)
880 .with_context(|| format!("parse Go OpenAPI sidecar output {}", path.display()))
881}
882
883fn parse_method(method: &str) -> Result<HttpMethod> {
884 HttpMethod::parse(method).map_err(anyhow::Error::msg)
885}
886
887fn operation_id(method: &str, path: &str) -> String {
888 let parts = path
889 .trim_matches('/')
890 .split('/')
891 .filter(|segment| !segment.is_empty())
892 .map(|segment| {
893 let segment = segment.trim_matches(['{', '}']);
894 let mut characters = segment.chars();
895 characters
896 .next()
897 .map(|first| format!("{}{}", first.to_uppercase(), characters.as_str()))
898 .unwrap_or_default()
899 })
900 .collect::<Vec<_>>()
901 .join("");
902 let prefix = method.to_ascii_lowercase();
903 if parts.is_empty() {
904 prefix
905 } else {
906 format!("{prefix}{parts}")
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 #[test]
913 fn repeated_source_ids_remain_distinct_and_stable_under_reordering() {
914 let operations = vec![
915 Operation {
916 id: "repeat".into(),
917 path: "/one".into(),
918 ..Default::default()
919 },
920 Operation {
921 id: "repeat".into(),
922 path: "/two".into(),
923 ..Default::default()
924 },
925 ];
926 let mut forward = operations.clone();
927 disambiguate_source_operation_ids(&mut forward).unwrap();
928 let mut reverse = operations;
929 reverse.reverse();
930 disambiguate_source_operation_ids(&mut reverse).unwrap();
931 reverse.reverse();
932 assert_eq!(forward, reverse);
933 assert_ne!(forward[0].id, forward[1].id);
934 assert_eq!(
935 forward[0].annotations["poolster.openapi.original_operation_id"],
936 "repeat"
937 );
938 forward[1].path = forward[0].path.clone();
939 assert!(disambiguate_source_operation_ids(&mut forward).is_err());
940 }
941
942 #[test]
943 fn openapi32_query_and_standard_methods_are_typed() {
944 for (wire, method) in [
945 ("HEAD", crate::HttpMethod::Head),
946 ("OPTIONS", crate::HttpMethod::Options),
947 ("TRACE", crate::HttpMethod::Trace),
948 ("QUERY", crate::HttpMethod::Query),
949 ] {
950 assert_eq!(super::parse_method(wire).unwrap(), method);
951 assert_eq!(method.as_str(), wire);
952 assert_eq!(
953 serde_json::from_str::<crate::HttpMethod>(&format!("\"{wire}\"")).unwrap(),
954 method
955 );
956 }
957 assert_eq!(
958 super::parse_method("COPY").unwrap(),
959 HttpMethod::Custom("COPY".into())
960 );
961 assert!(super::parse_method("BAD METHOD").is_err());
962 }
963
964 use super::*;
965 use std::fs;
966
967 #[test]
968 fn request_body_requires_the_current_media_type_shape() {
969 assert!(
970 serde_json::from_value::<SidecarBody>(serde_json::json!({
971 "content_type": "application/json", "schema_definition": {"type": "string"}
972 }))
973 .is_err()
974 );
975 let body: SidecarBody = serde_json::from_value(serde_json::json!({
976 "media_types": [{"content_type": "application/json", "schema_definition": {"type": "string"}}]
977 })).unwrap();
978 assert_eq!(convert_request_body(&body).media_types.len(), 1);
979 }
980
981 #[test]
982 fn security_catalog_artifact_is_required() {
983 let temp = tempfile::tempdir().unwrap();
984 assert!(load_security_schemes(temp.path()).is_err());
985 fs::write(
986 temp.path().join("security-schemes.json"),
987 r#"{"schemes":[]}"#,
988 )
989 .unwrap();
990 assert!(
991 load_security_schemes(temp.path())
992 .unwrap()
993 .schemes
994 .is_empty()
995 );
996 }
997
998 #[test]
999 fn schema_catalog_artifact_is_required() {
1000 let temp = tempfile::tempdir().unwrap();
1001 fs::write(temp.path().join("operations.json"), "{}").unwrap();
1002 fs::write(temp.path().join("operations-order.json"), "[]").unwrap();
1003 assert!(load_operations(temp.path(), "Empty".into(), "1".into()).is_err());
1004 fs::write(temp.path().join("schemas.json"), r#"{"schemas":[]}"#).unwrap();
1005 assert!(
1006 load_operations(temp.path(), "Empty".into(), "1".into())
1007 .unwrap()
1008 .schemas
1009 .is_empty()
1010 );
1011 }
1012
1013 #[test]
1014 fn preserves_composed_components_and_operation_schemas() {
1015 let temp = tempfile::tempdir().unwrap();
1016 let operations = temp.path().join("operations");
1017 fs::create_dir_all(&operations).unwrap();
1018 fs::write(temp.path().join("schemas.json"), r#"{"schemas":[]}"#).unwrap();
1019 fs::write(
1020 temp.path().join("operations.json"),
1021 r#"{"GET /pets/{petId}":"get.json"}"#,
1022 )
1023 .unwrap();
1024 fs::write(
1025 temp.path().join("operations-order.json"),
1026 r#"["GET /pets/{petId}"]"#,
1027 )
1028 .unwrap();
1029 fs::write(operations.join("get.json"), r##"{"path":"/pets/{petId}","method":"GET","request_body":{"media_types":[{"content_type":"application/json","schema_definition":{"$ref":"#/components/schemas/Pet"}}]},"responses":[{"code":"200","content_type":"application/json","schema_definition":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}}]}"##).unwrap();
1030 fs::write(temp.path().join("schemas.json"), r##"{"schemas":[{"name":"Pet","schema":{"type":"object","required":["name"],"additionalProperties":{"type":"string"},"properties":{"name":{"type":"string","format":"uuid"},"kind":{"type":["string","null"],"enum":["cat","dog"]}},"x-target-name":"animal"}},{"name":"Animal","schema":{"oneOf":[{"$ref":"#/components/schemas/Pet"},{"type":"integer"}],"nullable":true,"discriminator":{"propertyName":"kind","mapping":{"cat":"#/components/schemas/Pet"}}}}]}"##).unwrap();
1031 let api = load_operations(temp.path(), "Pets".into(), "1.0.0".into()).unwrap();
1032 assert_eq!(
1033 api.operations[0]
1034 .request_schema()
1035 .unwrap()
1036 .kind
1037 .reference_name(),
1038 Some("Pet")
1039 );
1040 assert!(matches!(
1041 api.operations[0].success_schema().unwrap().kind,
1042 SchemaKind::Array { .. }
1043 ));
1044 let SchemaKind::Object {
1045 fields,
1046 additional_properties,
1047 } = &api.schemas[0].value.kind
1048 else {
1049 panic!("Pet must be an object")
1050 };
1051 assert!(matches!(
1052 additional_properties,
1053 AdditionalProperties::Schema { .. }
1054 ));
1055 assert!(fields[0].value.nullable);
1056 assert_eq!(
1057 fields[0].value.enum_values,
1058 vec![Value::String("cat".into()), Value::String("dog".into())]
1059 );
1060 assert_eq!(
1061 api.schemas[0].value.extensions["x-target-name"],
1062 Value::String("animal".into())
1063 );
1064 let SchemaKind::OneOf { variants } = &api.schemas[1].value.kind else {
1065 panic!("Animal must be a union")
1066 };
1067 assert_eq!(variants.len(), 2);
1068 assert!(api.schemas[1].value.nullable);
1069 assert_eq!(
1070 api.schemas[1]
1071 .value
1072 .discriminator
1073 .as_ref()
1074 .unwrap()
1075 .property_name,
1076 "kind"
1077 );
1078 }
1079
1080 #[test]
1081 fn retains_all_of_and_constraints() {
1082 let value = convert_value(
1083 &serde_json::json!({"allOf":[{"$ref":"#/components/schemas/Base"},{"type":"object","additionalProperties":false}],"minimum":3}),
1084 );
1085 assert!(matches!(value.kind, SchemaKind::AllOf { .. }));
1086 assert_eq!(value.constraints["minimum"], Value::from(3));
1087 }
1088
1089 #[test]
1090 fn preserves_operation_transport_contracts() {
1091 let temp = tempfile::tempdir().unwrap();
1092 let operations = temp.path().join("operations");
1093 fs::create_dir_all(&operations).unwrap();
1094 fs::write(temp.path().join("schemas.json"), r#"{"schemas":[]}"#).unwrap();
1095 fs::write(
1096 temp.path().join("operations.json"),
1097 r#"{"POST /pets/{petId}":"post.json"}"#,
1098 )
1099 .unwrap();
1100 fs::write(
1101 temp.path().join("operations-order.json"),
1102 r#"["POST /pets/{petId}"]"#,
1103 )
1104 .unwrap();
1105 fs::write(
1106 operations.join("post.json"),
1107 r##"{
1108 "path":"/pets/{petId}","method":"POST",
1109 "parameters":[
1110 {"name":"petId","in":"path","required":true,"style":"matrix","explode":true,"schema":{"type":"string"}},
1111 {"name":"include","in":"query","style":"pipeDelimited","explode":false,"schema":{"type":"array","items":{"type":"string"}}}
1112 ],
1113 "request_body":{"required":true,"description":"New pet","media_types":[
1114 {"content_type":"application/json","schema_definition":{"$ref":"#/components/schemas/PetInput"}},
1115 {"content_type":"application/xml","schema_definition":{"type":"string"}},
1116 {"content_type":"multipart/form-data","schema_definition":{"type":"object"},"encoding":{"metadata":{"contentType":"application/json","style":"form","explode":false,"allowReserved":true,"headers":{"X-Part-Id":{"required":true,"style":"simple","explode":false,"schema_definition":{"type":"string"},"example_json":"\"metadata-1\""}}}}}
1117 ]},
1118 "responses":[
1119 {"code":"201","description":"Created","content_type":"application/json","schema_definition":{"$ref":"#/components/schemas/Pet"}},
1120 {"code":"201","description":"Created","content_type":"application/xml","schema_definition":{"type":"string"}},
1121 {"code":"default","description":"Failure"}
1122 ],
1123 "security_requirements":[
1124 {"schemes":{"oauth":["pets:write"],"api_key":[]}},
1125 {"schemes":{"anonymous":[]}}
1126 ]
1127 }"##,
1128 )
1129 .unwrap();
1130
1131 let api = load_operations(temp.path(), "Pets".into(), "1.0.0".into()).unwrap();
1132 let operation = &api.operations[0];
1133 assert_eq!(operation.parameters.len(), 2);
1134 assert_eq!(operation.parameters[0].location, "path");
1135 assert!(operation.parameters[0].required);
1136 assert_eq!(operation.parameters[0].annotations["style"], "matrix");
1137 assert_eq!(operation.parameters[0].annotations["explode"], true);
1138 assert_eq!(
1139 operation.parameters[1].annotations["style"],
1140 "pipeDelimited"
1141 );
1142 assert_eq!(operation.parameters[1].annotations["explode"], false);
1143 assert!(matches!(
1144 operation.parameters[1].schema.as_ref().unwrap().kind,
1145 SchemaKind::Array { .. }
1146 ));
1147 let request = operation.request_body.as_ref().unwrap();
1148 assert!(request.required);
1149 assert_eq!(request.media_types.len(), 3);
1150 assert_eq!(request.media_types[1].content_type, "application/xml");
1151 let encodings = operation
1152 .annotations
1153 .get("poolster.request_body_encodings")
1154 .and_then(Value::as_object)
1155 .unwrap();
1156 let metadata = &encodings["multipart/form-data"]["metadata"];
1157 assert_eq!(metadata["contentType"], "application/json");
1158 assert_eq!(metadata["style"], "form");
1159 assert_eq!(metadata["explode"], false);
1160 assert_eq!(metadata["allowReserved"], true);
1161 assert_eq!(metadata["headers"]["X-Part-Id"]["required"], true);
1162 assert_eq!(
1163 metadata["headers"]["X-Part-Id"]["schema_definition"]["type"],
1164 "string"
1165 );
1166 assert_eq!(operation.responses.len(), 2);
1167 assert_eq!(operation.responses[0].status, "201");
1168 assert_eq!(operation.responses[0].media_types.len(), 2);
1169 assert!(operation.responses[1].media_types.is_empty());
1170 assert_eq!(operation.security.len(), 2);
1171 assert_eq!(operation.security[0].schemes["oauth"], ["pets:write"]);
1172 assert!(operation.security[0].schemes.contains_key("api_key"));
1173 }
1174
1175 #[test]
1176 fn preserves_request_examples_for_docs_consumers() {
1177 let temp = tempfile::tempdir().unwrap();
1178 let operations = temp.path().join("operations");
1179 fs::create_dir_all(&operations).unwrap();
1180 fs::write(temp.path().join("schemas.json"), r#"{"schemas":[]}"#).unwrap();
1181 fs::write(
1182 temp.path().join("operations.json"),
1183 r#"{"POST /widgets":"post.json"}"#,
1184 )
1185 .unwrap();
1186 fs::write(
1187 temp.path().join("operations-order.json"),
1188 r#"["POST /widgets"]"#,
1189 )
1190 .unwrap();
1191 fs::write(
1192 operations.join("post.json"),
1193 r##"{
1194 "path":"/widgets", "method":"POST", "operation_id":"createWidget",
1195 "request_examples":[
1196 {"label":"Request application/json: minimal", "content_type":"application/json", "example_json":"{\"name\":\"widget\"}"},
1197 {"label":"Request application/json: complete", "content_type":"application/json", "example_json":"{\"name\":\"widget\",\"enabled\":true}"}
1198 ],
1199 "servers":[{"url":"https://{region}.example.test","variables":[{"name":"region","default":"eu"}]}],
1200 "tags":["Widgets"],
1201 "parameters":[{"name":"q","in":"query","allow_reserved":true,"example":"name=value"}],
1202 "responses":[{"code":"201","content_type":"application/json","example_json":"{\"id\":\"w1\"}"}]
1203 }"##,
1204 )
1205 .unwrap();
1206
1207 let api = load_operations(temp.path(), "Widgets".into(), "1.0.0".into()).unwrap();
1208 let examples = api.operations[0]
1209 .annotations
1210 .get("poolster.docs.request_examples")
1211 .and_then(Value::as_array)
1212 .unwrap();
1213 let operation = &api.operations[0];
1214 assert_eq!(
1215 operation.annotations["poolster.openapi.servers"][0]["variables"][0]["default"],
1216 "eu"
1217 );
1218 assert_eq!(operation.annotations["tags"][0], "Widgets");
1219 assert_eq!(operation.parameters[0].annotations["allowReserved"], true);
1220 assert_eq!(operation.parameters[0].annotations["example"], "name=value");
1221 assert_eq!(
1222 operation.annotations["poolster.docs.response_examples"][0]["status"],
1223 "201"
1224 );
1225 assert_eq!(
1226 operation.annotations["poolster.docs.response_examples"][0]["example_json"],
1227 r#"{"id":"w1"}"#
1228 );
1229 assert_eq!(examples.len(), 2);
1230 assert_eq!(examples[0]["label"], "Request application/json: minimal");
1231 assert_eq!(examples[0]["content_type"], "application/json");
1232 assert_eq!(
1233 examples[1]["example_json"],
1234 r#"{"name":"widget","enabled":true}"#
1235 );
1236 }
1237
1238 #[test]
1239 fn preserves_poolster_mock_contract_for_typed_extraction() {
1240 let temp = tempfile::tempdir().unwrap();
1241 let operations = temp.path().join("operations");
1242 fs::create_dir_all(&operations).unwrap();
1243 fs::write(temp.path().join("schemas.json"), r#"{"schemas":[]}"#).unwrap();
1244 fs::write(
1245 temp.path().join("operations.json"),
1246 r#"{"GET /widgets":"get.json"}"#,
1247 )
1248 .unwrap();
1249 fs::write(
1250 temp.path().join("operations-order.json"),
1251 r#"["GET /widgets"]"#,
1252 )
1253 .unwrap();
1254 fs::write(
1255 operations.join("get.json"),
1256 r##"{
1257 "path":"/widgets", "method":"GET", "operation_id":"listWidgets",
1258 "extensions": {
1259 "x-poolster-mock": {
1260 "scenarios": [{
1261 "name":"rate-limited",
1262 "when":{"headers":{"x-test-scenario":"rate-limited"}},
1263 "response":{"status":429,"headers":{"retry-after":"1"},"body":{"message":"Too many requests"}}
1264 }]
1265 }
1266 },
1267 "responses":[{"code":"200"}]
1268 }"##,
1269 )
1270 .unwrap();
1271
1272 let api = load_operations(temp.path(), "Widgets".into(), "1.0.0".into()).unwrap();
1273 assert!(
1274 api.operations[0]
1275 .annotations
1276 .contains_key("x-poolster-mock")
1277 );
1278 let scenarios = crate::extract_mock_scenarios(&api).unwrap();
1279 assert_eq!(scenarios.len(), 1);
1280 assert_eq!(scenarios[0].name, "rate-limited");
1281 assert_eq!(scenarios[0].response.status, 429);
1282 }
1283
1284 #[test]
1285 fn loads_security_scheme_catalog_without_losing_auth_metadata() {
1286 let temp = tempfile::tempdir().unwrap();
1287 fs::write(
1288 temp.path().join("security-schemes.json"),
1289 r#"{
1290 "schemes": [
1291 {
1292 "name": "ApiKey",
1293 "type": "apiKey",
1294 "description": "Tenant key",
1295 "api_key_name": "X-API-Key",
1296 "api_key_in": "header"
1297 },
1298 {
1299 "name": "Bearer",
1300 "type": "http",
1301 "http_scheme": "bearer",
1302 "bearer_format": "JWT"
1303 },
1304 {
1305 "name": "OAuth",
1306 "type": "oauth2",
1307 "oauth2_metadata_url": "https://example.test/metadata",
1308 "oauth_flows": [{
1309 "type": "authorizationCode",
1310 "authorization_url": "https://example.test/authorize",
1311 "token_url": "https://example.test/token",
1312 "refresh_url": "https://example.test/refresh",
1313 "scopes": { "pets:read": "Read pets" }
1314 }]
1315 }
1316 ]
1317 }"#,
1318 )
1319 .unwrap();
1320
1321 let catalog = load_security_schemes(temp.path()).unwrap();
1322 assert_eq!(catalog.schemes.len(), 3);
1323 assert!(matches!(
1324 &catalog.schemes[0].kind,
1325 SecuritySchemeKind::ApiKey {
1326 name: Some(name),
1327 location: Some(location),
1328 } if name == "X-API-Key" && location == "header"
1329 ));
1330 assert!(matches!(
1331 &catalog.schemes[1].kind,
1332 SecuritySchemeKind::Http {
1333 scheme: Some(scheme),
1334 bearer_format: Some(format),
1335 } if scheme == "bearer" && format == "JWT"
1336 ));
1337 let SecuritySchemeKind::OAuth2 {
1338 flows,
1339 metadata_url: Some(metadata_url),
1340 } = &catalog.schemes[2].kind
1341 else {
1342 panic!("OAuth scheme metadata was not loaded")
1343 };
1344 assert_eq!(metadata_url, "https://example.test/metadata");
1345 assert_eq!(flows[0].flow_type, "authorizationCode");
1346 assert_eq!(
1347 flows[0].token_url.as_deref(),
1348 Some("https://example.test/token")
1349 );
1350 assert_eq!(flows[0].scopes["pets:read"], "Read pets");
1351 }
1352}