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#[derive(Debug, Error)]
19pub enum ContractError {
20 #[error("failed to read environment variable: {0}")]
22 Env(#[from] VarError),
23 #[error("I/O error: {0}")]
25 Io(#[from] io::Error),
26 #[error("JSON error: {0}")]
28 Json(#[from] serde_json::Error),
29 #[error("invalid OpenAPI document: {0}")]
31 InvalidDocument(String),
32}
33
34#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
36pub enum ParameterLocationSeed {
37 Header,
39 Path,
41 Query,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct ParameterDescriptorSeed {
48 pub location: ParameterLocationSeed,
50 pub name: String,
52 pub required: bool,
54 pub description: Option<String>,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct RequestBodyDescriptorSeed {
61 pub content_type: Option<String>,
63 pub nullable: bool,
65 pub required: bool,
67 pub schema_name: Option<String>,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ResponseDescriptorSeed {
74 pub content_type: Option<String>,
76 pub is_error: bool,
78 pub schema_name: Option<String>,
80 pub status: u16,
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct OperationDescriptorSeed {
87 pub has_request_body: bool,
89 pub method: String,
91 pub operation_id: String,
93 pub parameters: Vec<ParameterDescriptorSeed>,
95 pub path: String,
97 pub primary_response_schema: Option<String>,
99 pub primary_success_status: u16,
101 pub request_body: Option<RequestBodyDescriptorSeed>,
103 pub requires_auth: bool,
105 pub responses: Vec<ResponseDescriptorSeed>,
107 pub tag: String,
109 pub summary: Option<String>,
111 pub description: Option<String>,
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct ContractRegistry {
118 pub operation_count: usize,
120 pub operations: Vec<OperationDescriptorSeed>,
122 pub path_count: usize,
124 pub schema_count: usize,
126 pub schemas: Vec<String>,
128 pub tags: Vec<String>,
130}
131
132#[derive(Clone, Debug)]
134pub struct GeneratedArtifacts {
135 pub normalized_json: String,
137 pub registry: ContractRegistry,
139}
140
141pub fn source_contract_path(manifest_dir: &Path) -> PathBuf {
143 manifest_dir.join("../../docs/openai.json")
144}
145
146pub fn normalized_contract_path(manifest_dir: &Path) -> PathBuf {
148 manifest_dir.join("../../target/prism/openai.prism.json")
149}
150
151pub 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
161pub 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
167pub 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
183pub 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
251pub 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}