Skip to main content

ferriskey_sdk/
contract.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    env::VarError,
4    fs, io,
5    path::{Path, PathBuf},
6};
7
8use serde_json::{Map, Value, json};
9use thiserror::Error;
10
11const HTTP_METHODS: [&str; 8] =
12    ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
13const SYNTHETIC_SERVER_URL: &str = "http://127.0.0.1:4010";
14const AUTHORIZATION_SCHEME_NAME: &str = "Authorization";
15const BEARER_SCOPE_NAME: &str = "Bearer";
16
17/// Errors raised while normalizing or generating FerrisKey contract artifacts.
18#[derive(Debug, Error)]
19pub enum ContractError {
20    /// Environment variable access failed.
21    #[error("failed to read environment variable: {0}")]
22    Env(#[from] VarError),
23    /// File-system access failed.
24    #[error("I/O error: {0}")]
25    Io(#[from] io::Error),
26    /// JSON parsing or serialization failed.
27    #[error("JSON error: {0}")]
28    Json(#[from] serde_json::Error),
29    /// The OpenAPI document is structurally invalid for this generator.
30    #[error("invalid OpenAPI document: {0}")]
31    InvalidDocument(String),
32}
33
34/// Supported OpenAPI parameter locations during registry generation.
35#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
36pub enum ParameterLocationSeed {
37    /// Header parameter.
38    Header,
39    /// Path parameter.
40    Path,
41    /// Query parameter.
42    Query,
43}
44
45/// Parameter descriptor seed before rendering the generated metadata module.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct ParameterDescriptorSeed {
48    /// Parameter location in the HTTP request.
49    pub location: ParameterLocationSeed,
50    /// Parameter name from the OpenAPI document.
51    pub name: String,
52    /// Whether the parameter is required.
53    pub required: bool,
54    /// Parameter description from the OpenAPI document.
55    pub description: Option<String>,
56}
57
58/// Request-body descriptor seed before rendering the generated metadata module.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct RequestBodyDescriptorSeed {
61    /// Preferred request content type.
62    pub content_type: Option<String>,
63    /// Whether the request body is nullable.
64    pub nullable: bool,
65    /// Whether the request body is required.
66    pub required: bool,
67    /// Referenced schema name when present.
68    pub schema_name: Option<String>,
69}
70
71/// Response descriptor seed before rendering the generated metadata module.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ResponseDescriptorSeed {
74    /// Preferred response content type.
75    pub content_type: Option<String>,
76    /// Whether the response represents an error status.
77    pub is_error: bool,
78    /// Referenced schema name when present.
79    pub schema_name: Option<String>,
80    /// HTTP status code documented for the response.
81    pub status: u16,
82}
83
84/// Operation descriptor seed before rendering the generated metadata module.
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct OperationDescriptorSeed {
87    /// Whether the operation accepts a request body.
88    pub has_request_body: bool,
89    /// HTTP method.
90    pub method: String,
91    /// Unique operation identifier.
92    pub operation_id: String,
93    /// Parameter descriptors for the operation.
94    pub parameters: Vec<ParameterDescriptorSeed>,
95    /// Path template from the contract.
96    pub path: String,
97    /// Primary success response schema when present.
98    pub primary_response_schema: Option<String>,
99    /// Primary success status code.
100    pub primary_success_status: u16,
101    /// Request-body descriptor when present.
102    pub request_body: Option<RequestBodyDescriptorSeed>,
103    /// Whether the operation requires authorization.
104    pub requires_auth: bool,
105    /// Documented response descriptors.
106    pub responses: Vec<ResponseDescriptorSeed>,
107    /// Primary API tag.
108    pub tag: String,
109    /// Short summary from the OpenAPI document.
110    pub summary: Option<String>,
111    /// Detailed description from the OpenAPI document.
112    pub description: Option<String>,
113}
114
115/// Normalized contract registry used by code generation and verification tests.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct ContractRegistry {
118    /// Number of documented operations.
119    pub operation_count: usize,
120    /// Generated operation descriptors.
121    pub operations: Vec<OperationDescriptorSeed>,
122    /// Number of documented paths.
123    pub path_count: usize,
124    /// Number of documented schemas.
125    pub schema_count: usize,
126    /// Generated schema names.
127    pub schemas: Vec<String>,
128    /// Generated tag names.
129    pub tags: Vec<String>,
130}
131
132/// Build-time artifacts produced from the normalized FerrisKey contract.
133#[derive(Clone, Debug)]
134pub struct GeneratedArtifacts {
135    /// Pretty-printed normalized contract JSON.
136    pub normalized_json: String,
137    /// Generated contract registry metadata.
138    pub registry: ContractRegistry,
139}
140
141/// Resolve the source FerrisKey contract path from a crate manifest directory.
142pub fn source_contract_path(manifest_dir: &Path) -> PathBuf {
143    manifest_dir.join("../../docs/openai.json")
144}
145
146/// Resolve the normalized Prism contract path from a crate manifest directory.
147pub fn normalized_contract_path(manifest_dir: &Path) -> PathBuf {
148    manifest_dir.join("../../target/prism/openai.prism.json")
149}
150
151/// Generate normalized contract artifacts and the in-memory registry.
152pub fn generate_artifacts(manifest_dir: &Path) -> Result<GeneratedArtifacts, ContractError> {
153    let source_document = load_contract(&source_contract_path(manifest_dir))?;
154    let normalized_document = normalize_contract(&source_document)?;
155    let registry = build_registry(&normalized_document)?;
156    let normalized_json = serde_json::to_string_pretty(&normalized_document)?;
157
158    Ok(GeneratedArtifacts { normalized_json, registry })
159}
160
161/// Load a JSON contract document from disk.
162pub fn load_contract(path: &Path) -> Result<Value, ContractError> {
163    let raw = fs::read_to_string(path)?;
164    serde_json::from_str(&raw).map_err(ContractError::Json)
165}
166
167/// Normalize the FerrisKey OpenAPI document for generation and Prism use.
168pub fn normalize_contract(source_document: &Value) -> Result<Value, ContractError> {
169    let mut normalized_document = source_document.clone();
170    let all_tags = collect_operation_tags(source_document)?;
171    let requires_authorization = operation_requires_authorization(source_document)?;
172
173    ensure_servers(&mut normalized_document)?;
174    ensure_complete_root_tags(&mut normalized_document, &all_tags)?;
175
176    if requires_authorization {
177        ensure_authorization_security_scheme(&mut normalized_document)?;
178    }
179
180    Ok(normalized_document)
181}
182
183/// Build a contract registry from a normalized OpenAPI document.
184pub fn build_registry(document: &Value) -> Result<ContractRegistry, ContractError> {
185    let paths = top_level_object(document, "paths")?;
186    let schema_names = schema_names(document)?;
187    let tags = collect_operation_tags(document)?;
188    let mut operations = Vec::new();
189
190    for (path, path_item) in paths {
191        let path_item_object = path_item.as_object().ok_or_else(|| {
192            ContractError::InvalidDocument(format!("path item for {path} must be an object"))
193        })?;
194
195        for method in HTTP_METHODS {
196            let Some(operation) = path_item_object.get(method) else {
197                continue;
198            };
199            let operation_object = operation.as_object().ok_or_else(|| {
200                ContractError::InvalidDocument(format!(
201                    "operation {method} {path} must be an object"
202                ))
203            })?;
204
205            let operation_id = string_field(operation_object, "operationId")?;
206            let tag = first_operation_tag(operation_object)?;
207            let parameters = collect_parameter_descriptors(path_item_object, operation_object)?;
208            let request_body = request_body_descriptor(operation_object)?;
209            let responses = response_descriptors(operation_object)?;
210            let primary_success_status = primary_success_status(&responses)?;
211            let primary_response_schema = responses
212                .iter()
213                .find(|response| response.status == primary_success_status)
214                .and_then(|response| response.schema_name.clone());
215            let requires_auth = operation_has_authorization(operation_object)?;
216            let summary =
217                operation_object.get("summary").and_then(Value::as_str).map(ToOwned::to_owned);
218            let description =
219                operation_object.get("description").and_then(Value::as_str).map(ToOwned::to_owned);
220
221            operations.push(OperationDescriptorSeed {
222                has_request_body: request_body.is_some(),
223                method: method.to_ascii_uppercase(),
224                operation_id,
225                parameters,
226                path: path.clone(),
227                primary_response_schema,
228                primary_success_status,
229                request_body,
230                requires_auth,
231                responses,
232                tag,
233                summary,
234                description,
235            });
236        }
237    }
238
239    operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id));
240
241    Ok(ContractRegistry {
242        operation_count: operations.len(),
243        operations,
244        path_count: paths.len(),
245        schema_count: schema_names.len(),
246        schemas: schema_names,
247        tags,
248    })
249}
250
251/// Render the generated Rust metadata module for the normalized contract registry.
252pub fn render_generated_module(registry: &ContractRegistry) -> String {
253    let tag_modules = registry
254        .tags
255        .iter()
256        .map(|tag| {
257            let operation_ids = registry
258                .operations
259                .iter()
260                .filter(|operation| operation.tag == *tag)
261                .map(|operation| format!("            {:?}", operation.operation_id))
262                .collect::<Vec<_>>()
263                .join(",\n");
264
265            format!(
266                "    #[doc = \"Generated operation identifiers for this FerrisKey API tag.\"]\n    pub mod {} {{\n        /// Operation identifiers grouped under this API tag.\n        pub const OPERATION_IDS: &[&str] = &[\n{}\n        ];\n    }}",
267                sanitize_module_identifier(tag),
268                operation_ids,
269            )
270        })
271        .collect::<Vec<_>>()
272        .join("\n\n");
273    let tag_names =
274        registry.tags.iter().map(|tag| format!("    {:?}", tag)).collect::<Vec<_>>().join(",\n");
275    let schema_names = registry
276        .schemas
277        .iter()
278        .map(|schema| format!("        {:?}", schema))
279        .collect::<Vec<_>>()
280        .join(",\n");
281    let operation_descriptors =
282        registry.operations.iter().map(render_operation_descriptor).collect::<Vec<_>>().join(",\n");
283    let schema_aliases = registry
284        .schemas
285        .iter()
286        .map(|schema| {
287            format!(
288                "    #[doc = \"Generated schema alias from the FerrisKey OpenAPI document.\"]\n    pub type {} = serde_json::Value;",
289                sanitize_identifier(schema, true)
290            )
291        })
292        .collect::<Vec<_>>()
293        .join("\n");
294
295    format!(
296        "/// Generated parameter location metadata.\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub enum ParameterLocation {{\n    /// Header-bound parameter.\n    Header,\n    /// Path-bound parameter.\n    Path,\n    /// Query-bound parameter.\n    Query,\n}}\n\n/// Generated parameter descriptor metadata.\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub struct GeneratedParameterDescriptor {{\n    /// Parameter location in the HTTP request.\n    pub location: ParameterLocation,\n    /// Parameter name from the contract.\n    pub name: &'static str,\n    /// Whether the parameter is required by the contract.\n    pub required: bool,\n    /// Parameter description from the contract.\n    pub description: Option<&'static str>,\n}}\n\n/// Generated request-body descriptor metadata.\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub struct GeneratedRequestBodyDescriptor {{\n    /// Preferred request content type.\n    pub content_type: Option<&'static str>,\n    /// Whether the request body is nullable.\n    pub nullable: bool,\n    /// Whether the request body is required.\n    pub required: bool,\n    /// Referenced schema name when present.\n    pub schema_name: Option<&'static str>,\n}}\n\n/// Generated response descriptor metadata.\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub struct GeneratedResponseDescriptor {{\n    /// Preferred response content type.\n    pub content_type: Option<&'static str>,\n    /// Whether the response represents an error status.\n    pub is_error: bool,\n    /// Referenced schema name when present.\n    pub schema_name: Option<&'static str>,\n    /// HTTP status code documented for the response.\n    pub status: u16,\n}}\n\n/// Generated operation descriptor metadata.\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub struct GeneratedOperationDescriptor {{\n    /// Unique operation identifier.\n    pub operation_id: &'static str,\n    /// HTTP method.\n    pub method: &'static str,\n    /// Path template from the contract.\n    pub path: &'static str,\n    /// Primary API tag.\n    pub tag: &'static str,\n    /// Whether the operation accepts a request body.\n    pub has_request_body: bool,\n    /// Short summary from the contract.\n    pub summary: Option<&'static str>,\n    /// Detailed description from the contract.\n    pub description: Option<&'static str>,\n    /// Schema name for the primary success response when present.\n    pub primary_response_schema: Option<&'static str>,\n    /// Primary success status code.\n    pub primary_success_status: u16,\n    /// Contract parameter descriptors.\n    pub parameters: &'static [GeneratedParameterDescriptor],\n    /// Contract request-body descriptor.\n    pub request_body: Option<GeneratedRequestBodyDescriptor>,\n    /// Whether the operation requires authorization.\n    pub requires_auth: bool,\n    /// Documented response descriptors.\n    pub responses: &'static [GeneratedResponseDescriptor],\n}}\n\n/// Number of documented paths in the normalized contract.\npub const PATH_COUNT: usize = {};\n/// Number of documented operations in the normalized contract.\npub const OPERATION_COUNT: usize = {};\n/// Number of documented schemas in the normalized contract.\npub const SCHEMA_COUNT: usize = {};\n/// Ordered tag names derived from the normalized contract.\npub const TAG_NAMES: &[&str] = &[\n{}\n];\n/// Generated operation descriptors derived from the normalized contract.\npub const OPERATION_DESCRIPTORS: &[GeneratedOperationDescriptor] = &[\n{}\n];\n\n/// Generated schema aliases derived from the normalized contract.\npub mod models {{\n    /// Ordered schema names derived from the normalized contract.\n    pub const SCHEMA_NAMES: &[&str] = &[\n{}\n    ];\n\n{}\n}}\n\n/// Generated tag groupings derived from the normalized contract.\npub mod tags {{\n{}\n}}\n",
297        registry.path_count,
298        registry.operation_count,
299        registry.schema_count,
300        tag_names,
301        operation_descriptors,
302        schema_names,
303        schema_aliases,
304        tag_modules,
305    )
306}
307
308fn render_operation_descriptor(operation: &OperationDescriptorSeed) -> String {
309    format!(
310        "    GeneratedOperationDescriptor {{ operation_id: {:?}, method: {:?}, path: {:?}, tag: {:?}, has_request_body: {}, summary: {}, description: {}, primary_response_schema: {}, primary_success_status: {}, parameters: &[{}], request_body: {}, requires_auth: {}, responses: &[{}] }}",
311        operation.operation_id,
312        operation.method,
313        operation.path,
314        operation.tag,
315        operation.has_request_body,
316        render_option_str(operation.summary.as_deref()),
317        render_option_str(operation.description.as_deref()),
318        render_option_str(operation.primary_response_schema.as_deref()),
319        operation.primary_success_status,
320        render_parameter_descriptors(&operation.parameters),
321        render_request_body_descriptor(operation.request_body.as_ref()),
322        operation.requires_auth,
323        render_response_descriptors(&operation.responses),
324    )
325}
326
327fn render_parameter_descriptors(parameters: &[ParameterDescriptorSeed]) -> String {
328    parameters
329        .iter()
330        .map(|parameter| {
331            format!(
332                "GeneratedParameterDescriptor {{ location: ParameterLocation::{}, name: {:?}, required: {}, description: {} }}",
333                render_parameter_location(parameter.location),
334                parameter.name,
335                parameter.required,
336                render_option_str(parameter.description.as_deref()),
337            )
338        })
339        .collect::<Vec<_>>()
340        .join(", ")
341}
342
343fn render_request_body_descriptor(request_body: Option<&RequestBodyDescriptorSeed>) -> String {
344    match request_body {
345        Some(request_body) => format!(
346            "Some(GeneratedRequestBodyDescriptor {{ content_type: {}, nullable: {}, required: {}, schema_name: {} }})",
347            render_option_str(request_body.content_type.as_deref()),
348            request_body.nullable,
349            request_body.required,
350            render_option_str(request_body.schema_name.as_deref()),
351        ),
352        None => "None".to_string(),
353    }
354}
355
356fn render_response_descriptors(responses: &[ResponseDescriptorSeed]) -> String {
357    responses
358        .iter()
359        .map(|response| {
360            format!(
361                "GeneratedResponseDescriptor {{ content_type: {}, is_error: {}, schema_name: {}, status: {} }}",
362                render_option_str(response.content_type.as_deref()),
363                response.is_error,
364                render_option_str(response.schema_name.as_deref()),
365                response.status,
366            )
367        })
368        .collect::<Vec<_>>()
369        .join(", ")
370}
371
372fn render_option_str(value: Option<&str>) -> String {
373    match value {
374        Some(value) => format!("Some({value:?})"),
375        None => "None".to_string(),
376    }
377}
378
379const fn render_parameter_location(location: ParameterLocationSeed) -> &'static str {
380    match location {
381        ParameterLocationSeed::Header => "Header",
382        ParameterLocationSeed::Path => "Path",
383        ParameterLocationSeed::Query => "Query",
384    }
385}
386
387fn primary_success_status(responses: &[ResponseDescriptorSeed]) -> Result<u16, ContractError> {
388    responses
389        .iter()
390        .find(|response| response.status >= 200 && response.status < 300)
391        .map(|response| response.status)
392        .or_else(|| responses.first().map(|response| response.status))
393        .ok_or_else(|| {
394            ContractError::InvalidDocument(
395                "operation responses must contain at least one numeric status code".to_string(),
396            )
397        })
398}
399
400fn collect_parameter_descriptors(
401    path_item_object: &Map<String, Value>,
402    operation_object: &Map<String, Value>,
403) -> Result<Vec<ParameterDescriptorSeed>, ContractError> {
404    let mut parameters = BTreeMap::new();
405
406    for value in [path_item_object.get("parameters"), operation_object.get("parameters")]
407        .into_iter()
408        .flatten()
409    {
410        let parameter_array = value.as_array().ok_or_else(|| {
411            ContractError::InvalidDocument("operation parameters must be an array".to_string())
412        })?;
413
414        for parameter in parameter_array {
415            let parameter_object = parameter.as_object().ok_or_else(|| {
416                ContractError::InvalidDocument("parameter entry must be an object".to_string())
417            })?;
418            let name = string_field(parameter_object, "name")?;
419            let location = parse_parameter_location(&string_field(parameter_object, "in")?)?;
420            let required = match location {
421                ParameterLocationSeed::Path => true,
422                _ => parameter_object.get("required").and_then(Value::as_bool).unwrap_or(false),
423            };
424
425            let description =
426                parameter_object.get("description").and_then(Value::as_str).map(ToOwned::to_owned);
427
428            parameters.insert(
429                (location, name.clone()),
430                ParameterDescriptorSeed { location, name, required, description },
431            );
432        }
433    }
434
435    Ok(parameters.into_values().collect())
436}
437
438fn parse_parameter_location(value: &str) -> Result<ParameterLocationSeed, ContractError> {
439    match value {
440        "header" => Ok(ParameterLocationSeed::Header),
441        "path" => Ok(ParameterLocationSeed::Path),
442        "query" => Ok(ParameterLocationSeed::Query),
443        other => {
444            Err(ContractError::InvalidDocument(format!("unsupported parameter location: {other}")))
445        }
446    }
447}
448
449fn request_body_descriptor(
450    operation_object: &Map<String, Value>,
451) -> Result<Option<RequestBodyDescriptorSeed>, ContractError> {
452    let Some(request_body_value) = operation_object.get("requestBody") else {
453        return Ok(None);
454    };
455    let request_body_object = request_body_value.as_object().ok_or_else(|| {
456        ContractError::InvalidDocument("requestBody must be an object".to_string())
457    })?;
458    let content =
459        request_body_object.get("content").and_then(Value::as_object).ok_or_else(|| {
460            ContractError::InvalidDocument("requestBody.content must be an object".to_string())
461        })?;
462    let (content_type, media_type) = preferred_media_type(content).ok_or_else(|| {
463        ContractError::InvalidDocument("requestBody.content cannot be empty".to_string())
464    })?;
465
466    Ok(Some(RequestBodyDescriptorSeed {
467        content_type: Some(content_type.to_string()),
468        nullable: schema_nullable(media_type)?,
469        required: request_body_object.get("required").and_then(Value::as_bool).unwrap_or(false),
470        schema_name: schema_name_from_media_type(media_type)?,
471    }))
472}
473
474fn response_descriptors(
475    operation_object: &Map<String, Value>,
476) -> Result<Vec<ResponseDescriptorSeed>, ContractError> {
477    let responses_value = operation_object.get("responses").ok_or_else(|| {
478        ContractError::InvalidDocument("operation responses are required".to_string())
479    })?;
480    let responses = responses_value.as_object().ok_or_else(|| {
481        ContractError::InvalidDocument("operation responses must be an object".to_string())
482    })?;
483    let mut descriptors = responses
484        .iter()
485        .filter_map(|(status, response)| {
486            status.parse::<u16>().ok().map(|status| (status, response))
487        })
488        .map(|(status, response)| {
489            let response_object = response.as_object().ok_or_else(|| {
490                ContractError::InvalidDocument("response entry must be an object".to_string())
491            })?;
492            let media = response_object.get("content").and_then(Value::as_object);
493            let (content_type, media_type) = media
494                .and_then(preferred_media_type)
495                .map_or((None, None), |(content_type, media_type)| {
496                    (Some(content_type.to_string()), Some(media_type))
497                });
498
499            Ok(ResponseDescriptorSeed {
500                content_type,
501                is_error: status >= 400,
502                schema_name: media_type.map(schema_name_from_media_type).transpose()?.flatten(),
503                status,
504            })
505        })
506        .collect::<Result<Vec<_>, ContractError>>()?;
507
508    descriptors.sort_by_key(|response| response.status);
509    Ok(descriptors)
510}
511
512fn preferred_media_type(content: &Map<String, Value>) -> Option<(&str, &Map<String, Value>)> {
513    content
514        .iter()
515        .find(|(content_type, _)| is_json_content_type(content_type))
516        .or_else(|| content.iter().next())
517        .and_then(|(content_type, media_type)| {
518            media_type.as_object().map(|media_type| (content_type.as_str(), media_type))
519        })
520}
521
522fn schema_nullable(media_type: &Map<String, Value>) -> Result<bool, ContractError> {
523    let Some(schema) = media_type.get("schema") else {
524        return Ok(false);
525    };
526    let schema_object = schema
527        .as_object()
528        .ok_or_else(|| ContractError::InvalidDocument("schema must be an object".to_string()))?;
529
530    if schema_object.get("nullable").and_then(Value::as_bool).unwrap_or(false) {
531        return Ok(true);
532    }
533
534    match schema_object.get("type") {
535        Some(Value::Array(items)) => Ok(items.iter().any(|item| item.as_str() == Some("null"))),
536        _ => Ok(false),
537    }
538}
539
540fn schema_name_from_media_type(
541    media_type: &Map<String, Value>,
542) -> Result<Option<String>, ContractError> {
543    let Some(schema) = media_type.get("schema") else {
544        return Ok(None);
545    };
546    let schema_object = schema
547        .as_object()
548        .ok_or_else(|| ContractError::InvalidDocument("schema must be an object".to_string()))?;
549    let Some(reference) = schema_object.get("$ref").and_then(Value::as_str) else {
550        return Ok(None);
551    };
552
553    Ok(reference.strip_prefix("#/components/schemas/").map(ToOwned::to_owned))
554}
555
556fn is_json_content_type(content_type: &str) -> bool {
557    content_type == "application/json" || content_type.ends_with("+json")
558}
559
560fn collect_operation_tags(document: &Value) -> Result<Vec<String>, ContractError> {
561    let paths = top_level_object(document, "paths")?;
562    let mut tags = BTreeSet::new();
563
564    for path_item in paths.values() {
565        let path_item_object = path_item.as_object().ok_or_else(|| {
566            ContractError::InvalidDocument("path item must be an object".to_string())
567        })?;
568
569        for method in HTTP_METHODS {
570            let Some(operation) = path_item_object.get(method) else {
571                continue;
572            };
573            let operation_object = operation.as_object().ok_or_else(|| {
574                ContractError::InvalidDocument("operation must be an object".to_string())
575            })?;
576            tags.insert(first_operation_tag(operation_object)?);
577        }
578    }
579
580    Ok(tags.into_iter().collect())
581}
582
583fn ensure_servers(document: &mut Value) -> Result<(), ContractError> {
584    let object = root_object_mut(document)?;
585    let needs_servers = match object.get("servers") {
586        None => true,
587        Some(Value::Array(servers)) => servers.is_empty(),
588        Some(_) => false,
589    };
590
591    if needs_servers {
592        object.insert("servers".to_string(), json!([{ "url": SYNTHETIC_SERVER_URL }]));
593    }
594
595    Ok(())
596}
597
598fn ensure_complete_root_tags(
599    document: &mut Value,
600    operation_tags: &[String],
601) -> Result<(), ContractError> {
602    let object = root_object_mut(document)?;
603    let mut existing_tags = BTreeMap::new();
604
605    if let Some(tags_value) = object.get("tags") {
606        let tags_array = tags_value.as_array().ok_or_else(|| {
607            ContractError::InvalidDocument("top-level tags must be an array".to_string())
608        })?;
609        for tag_value in tags_array {
610            let tag_object = tag_value.as_object().ok_or_else(|| {
611                ContractError::InvalidDocument("tag entry must be an object".to_string())
612            })?;
613            let name = string_field(tag_object, "name")?;
614            existing_tags.insert(name, tag_value.clone());
615        }
616    }
617
618    let normalized_tags = operation_tags
619        .iter()
620        .map(|tag| existing_tags.get(tag).cloned().unwrap_or_else(|| json!({ "name": tag })))
621        .collect::<Vec<_>>();
622
623    object.insert("tags".to_string(), Value::Array(normalized_tags));
624    Ok(())
625}
626
627fn ensure_authorization_security_scheme(document: &mut Value) -> Result<(), ContractError> {
628    let object = root_object_mut(document)?;
629    let components =
630        object.entry("components".to_string()).or_insert_with(|| Value::Object(Map::new()));
631    let components_object = components.as_object_mut().ok_or_else(|| {
632        ContractError::InvalidDocument("components must be an object".to_string())
633    })?;
634    let security_schemes = components_object
635        .entry("securitySchemes".to_string())
636        .or_insert_with(|| Value::Object(Map::new()));
637    let security_schemes_object = security_schemes.as_object_mut().ok_or_else(|| {
638        ContractError::InvalidDocument("components.securitySchemes must be an object".to_string())
639    })?;
640
641    security_schemes_object.entry(AUTHORIZATION_SCHEME_NAME.to_string()).or_insert_with(|| {
642        json!({
643            "type": "http",
644            "scheme": "bearer",
645            "bearerFormat": "JWT"
646        })
647    });
648
649    Ok(())
650}
651
652fn operation_requires_authorization(document: &Value) -> Result<bool, ContractError> {
653    let paths = top_level_object(document, "paths")?;
654
655    for path_item in paths.values() {
656        let path_item_object = path_item.as_object().ok_or_else(|| {
657            ContractError::InvalidDocument("path item must be an object".to_string())
658        })?;
659        for method in HTTP_METHODS {
660            let Some(operation) = path_item_object.get(method) else {
661                continue;
662            };
663            let operation_object = operation.as_object().ok_or_else(|| {
664                ContractError::InvalidDocument("operation must be an object".to_string())
665            })?;
666            if operation_has_authorization(operation_object)? {
667                return Ok(true);
668            }
669        }
670    }
671
672    Ok(false)
673}
674
675fn operation_has_authorization(
676    operation_object: &Map<String, Value>,
677) -> Result<bool, ContractError> {
678    let Some(security_value) = operation_object.get("security") else {
679        return Ok(false);
680    };
681    let security_array = security_value.as_array().ok_or_else(|| {
682        ContractError::InvalidDocument("operation security must be an array".to_string())
683    })?;
684
685    for security_entry in security_array {
686        let security_object = security_entry.as_object().ok_or_else(|| {
687            ContractError::InvalidDocument("security entry must be an object".to_string())
688        })?;
689        if let Some(scopes_value) = security_object.get(AUTHORIZATION_SCHEME_NAME) {
690            let scopes = scopes_value.as_array().ok_or_else(|| {
691                ContractError::InvalidDocument(
692                    "security scheme scopes must be an array".to_string(),
693                )
694            })?;
695            if scopes.iter().any(|scope| scope.as_str() == Some(BEARER_SCOPE_NAME)) {
696                return Ok(true);
697            }
698        }
699    }
700
701    Ok(false)
702}
703
704fn schema_names(document: &Value) -> Result<Vec<String>, ContractError> {
705    let components = top_level_object(document, "components")?;
706    let schemas_value = components.get("schemas").ok_or_else(|| {
707        ContractError::InvalidDocument("components.schemas is required".to_string())
708    })?;
709    let schemas_object = schemas_value.as_object().ok_or_else(|| {
710        ContractError::InvalidDocument("components.schemas must be an object".to_string())
711    })?;
712    let mut names = schemas_object.keys().cloned().collect::<Vec<_>>();
713    names.sort();
714    Ok(names)
715}
716
717fn first_operation_tag(operation_object: &Map<String, Value>) -> Result<String, ContractError> {
718    let tags_value = operation_object
719        .get("tags")
720        .ok_or_else(|| ContractError::InvalidDocument("operation tags are required".to_string()))?;
721    let tags = tags_value.as_array().ok_or_else(|| {
722        ContractError::InvalidDocument("operation tags must be an array".to_string())
723    })?;
724    let Some(first_tag) = tags.first().and_then(Value::as_str) else {
725        return Err(ContractError::InvalidDocument(
726            "operation tag list must contain a string".to_string(),
727        ));
728    };
729
730    Ok(first_tag.to_string())
731}
732
733fn root_object_mut(document: &mut Value) -> Result<&mut Map<String, Value>, ContractError> {
734    document.as_object_mut().ok_or_else(|| {
735        ContractError::InvalidDocument("OpenAPI document must be a JSON object".to_string())
736    })
737}
738
739fn top_level_object<'a>(
740    document: &'a Value,
741    field: &str,
742) -> Result<&'a Map<String, Value>, ContractError> {
743    let object = document.as_object().ok_or_else(|| {
744        ContractError::InvalidDocument("OpenAPI document must be a JSON object".to_string())
745    })?;
746    let value = object.get(field).ok_or_else(|| {
747        ContractError::InvalidDocument(format!("missing top-level field: {field}"))
748    })?;
749
750    value.as_object().ok_or_else(|| {
751        ContractError::InvalidDocument(format!("top-level field {field} must be an object"))
752    })
753}
754
755fn string_field(object: &Map<String, Value>, field: &str) -> Result<String, ContractError> {
756    let value = object.get(field).ok_or_else(|| {
757        ContractError::InvalidDocument(format!("missing required string field: {field}"))
758    })?;
759
760    value
761        .as_str()
762        .map(ToOwned::to_owned)
763        .ok_or_else(|| ContractError::InvalidDocument(format!("field {field} must be a string")))
764}
765
766fn sanitize_identifier(raw: &str, pascal_case: bool) -> String {
767    let mut segments = raw
768        .split(|character: char| !character.is_ascii_alphanumeric())
769        .filter(|segment| !segment.is_empty())
770        .map(|segment| segment.to_string())
771        .collect::<Vec<_>>();
772
773    if segments.is_empty() {
774        return if pascal_case {
775            "GeneratedValue".to_string()
776        } else {
777            "generated_value".to_string()
778        };
779    }
780
781    if pascal_case {
782        let mut identifier = String::new();
783
784        for segment in &mut segments {
785            let mut characters = segment.chars();
786            let Some(first_character) = characters.next() else {
787                continue;
788            };
789            identifier.push(first_character.to_ascii_uppercase());
790            identifier.push_str(&characters.as_str().to_ascii_lowercase());
791        }
792
793        if identifier.chars().next().is_some_and(|character| character.is_ascii_digit()) {
794            identifier.insert(0, '_');
795        }
796
797        identifier
798    } else {
799        let mut identifier = segments
800            .iter_mut()
801            .enumerate()
802            .map(|(index, segment)| {
803                if index == 0 {
804                    segment.to_ascii_lowercase()
805                } else {
806                    let mut characters = segment.chars();
807                    let Some(first_character) = characters.next() else {
808                        return String::new();
809                    };
810                    let remainder = characters.as_str().to_ascii_lowercase();
811                    format!("{}{}", first_character.to_ascii_uppercase(), remainder)
812                }
813            })
814            .collect::<String>();
815
816        if identifier.chars().next().is_some_and(|character| character.is_ascii_digit()) {
817            identifier.insert(0, '_');
818        }
819
820        identifier
821    }
822}
823
824fn sanitize_module_identifier(raw: &str) -> String {
825    let mut identifier = String::new();
826    let mut previous_was_separator = true;
827
828    for character in raw.chars() {
829        if !character.is_ascii_alphanumeric() {
830            if !identifier.is_empty() && !identifier.ends_with('_') {
831                identifier.push('_');
832            }
833            previous_was_separator = true;
834            continue;
835        }
836
837        if character.is_ascii_uppercase() && !previous_was_separator && !identifier.ends_with('_') {
838            identifier.push('_');
839        }
840
841        identifier.push(character.to_ascii_lowercase());
842        previous_was_separator = false;
843    }
844
845    while identifier.ends_with('_') {
846        identifier.pop();
847    }
848
849    if identifier.is_empty() {
850        return "generated_value".to_string();
851    }
852
853    if identifier.chars().next().is_some_and(|character| character.is_ascii_digit()) {
854        identifier.insert(0, '_');
855    }
856
857    identifier
858}