Skip to main content

poolster_core/adapter/
openapi_sidecar.rs

1//! Adapter for the JSON emitted by `docs-compiler/openapi`.
2//!
3//! The Go helper remains the temporary OpenAPI parser. This module is the
4//! narrow boundary that turns its JSON-compatible schema definitions into the
5//! Rust-native, target-neutral AST used by every generator.
6
7use 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/// An [`Adapter`] over the artifact directory emitted by Poolster's bundled Go
24/// OpenAPI compiler.
25///
26/// The free `load_operations` and `load_security_schemes` functions remain
27/// available for callers migrating from earlier Poolster releases. New code can
28/// use this type anywhere an input [`Adapter`] is accepted.
29#[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    /// Reads the current sidecar artifacts into a normalized API contract.
50    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/// Kept source-compatible with `openapi.ExampleDoc`, which is also the shape
146/// consumed by Poolster Docs' operation playground payload.
147#[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    // OpenAPI permits an empty OAuth scope array. The Go sidecar serializes
160    // that empty slice as `null`, so retain it as the semantic empty vector
161    // instead of rejecting otherwise valid API-key and bearer requirements.
162    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
222/// Loads an API operation set from a completed Go-sidecar output directory.
223/// Component, request, and response schemas go through the same conversion,
224/// preventing target generators from knowing the temporary Go output format.
225pub 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            // Preserve per-operation examples for documentation generators.
265            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(&parameter.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
420// Some public documents reuse operationId across distinct wire endpoints.
421// Rename every member of a duplicate group, independent of input ordering.
422fn 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
463/// Loads reusable OpenAPI component security schemes from the Go-sidecar
464/// `security-schemes.json` artifact. Operation requirements refer to these
465/// definitions by name; the artifact is required even when the catalog is empty.
466pub 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    // The Go sidecar emits one entry per (status, media type). Group it here so
614    // the neutral AST reflects OpenAPI's actual response shape while retaining
615    // output order deterministically.
616    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
659/// Converts an OpenAPI Schema Object represented as JSON without rendering a
660/// target-language type string or discarding a schema composition keyword.
661pub(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}