diff --git a/check_diff.py b/check_diff.py new file mode 100644 index 0000000..133bb90 --- /dev/null +++ b/check_diff.py @@ -0,0 +1,28 @@ +import json + +left_str = """{"components": {"schemas": {"ErrorBodySchema": {"properties": {"error_type": {"type": "string"}, "fields": {"items": {"": "#/components/schemas/FieldErrorSchema"}, "type": ["array", "null"]}, "message": {"type": "string"}}, "required": ["error_type", "message"], "type": "object"}, "ErrorSchema": {"properties": {"error": {"": "#/components/schemas/ErrorBodySchema"}, "request_id": {"type": ["string", "null"]}}, "required": ["error"], "type": "object"}, "FieldErrorSchema": {"properties": {"code": {"type": "string"}, "field": {"type": "string"}, "message": {"type": "string"}}, "required": ["field", "code", "message"], "type": "object"}, "SnapshotUser": {"properties": {"id": {"format": "int64", "type": "integer"}, "username": {"type": "string"}}, "required": ["id", "username"], "type": "object"}, "ValidationErrorBodySchema": {"properties": {"error_type": {"type": "string"}, "fields": {"items": {"": "#/components/schemas/FieldErrorSchema"}, "type": "array"}, "message": {"type": "string"}}, "required": ["error_type", "message", "fields"], "type": "object"}, "ValidationErrorSchema": {"properties": {"error": {"": "#/components/schemas/ValidationErrorBodySchema"}}, "required": ["error"], "type": "object"}}, "info": {"description": "Test Description", "title": "Snapshot API", "version": "1.0.0"}, "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "openapi": "3.1.0", "paths": {"/users": {"get": {"responses": {"200": {"content": {"text/plain": {"schema": {"type": "string"}}}, "description": "Successful response"}}}}, "/users/{id}": {"get": {"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"content": {"text/plain": {"schema": {"type": "string"}}}, "description": "Successful response"}}}}}}""" + +right_str = """{"components": {"schemas": {"ErrorBodySchema": {"properties": {"error_type": {"type": "string"}, "fields": {"items": {"": "#/components/schemas/FieldErrorSchema"}, "type": ["array", "null"]}, "message": {"type": "string"}}, "required": ["error_type", "message"], "type": "object"}, "ErrorSchema": {"properties": {"error": {"": "#/components/schemas/ErrorBodySchema"}, "request_id": {"type": ["string", "null"]}}, "required": ["error"], "type": "object"}, "FieldErrorSchema": {"properties": {"code": {"type": "string"}, "field": {"type": "string"}, "message": {"type": "string"}}, "required": ["field", "code", "message"], "type": "object"}, "SnapshotUser": {"properties": {"id": {"format": "int64", "type": "integer"}, "username": {"type": "string"}}, "required": ["id", "username"], "type": "object"}, "ValidationErrorBodySchema": {"properties": {"error_type": {"type": "string"}, "fields": {"items": {"": "#/components/schemas/FieldErrorSchema"}, "type": "string"}, "message": {"type": "string"}}, "required": ["error_type", "message", "fields"], "type": "object"}, "ValidationErrorSchema": {"properties": {"error": {"": "#/components/schemas/ValidationErrorBodySchema"}}, "required": ["error"], "type": "object"}}, "info": {"description": "Test Description", "title": "Snapshot API", "version": "1.0.0"}, "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "openapi": "3.1.0", "paths": {"/users": {"get": {"responses": {"200": {"content": {"text/plain": {"schema": {"type": "string"}}}, "description": "Successful response"}}}}, "/users/{id}": {"get": {"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"content": {"text/plain": {"schema": {"type": "string"}}}, "description": "Successful response"}}}}}}""" + +left = json.loads(left_str) +right = json.loads(right_str) + +def compare(path, l, r): + if l != r: + print(f"Difference at {path}: {l} != {r}") + if isinstance(l, dict) and isinstance(r, dict): + for k in l: + if k in r: + compare(f"{path}.{k}", l[k], r[k]) + else: + print(f"Missing key in right: {path}.{k}") + for k in r: + if k not in l: + print(f"Missing key in left: {path}.{k}") + elif isinstance(l, list) and isinstance(r, list): + if len(l) != len(r): + print(f"Length mismatch at {path}") + for i in range(min(len(l), len(r))): + compare(f"{path}[{i}]", l[i], r[i]) + +compare("root", left, right) diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index aedf436..1979364 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -6,7 +6,7 @@ use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BOD use crate::response::IntoResponse; use crate::router::{MethodRouter, Router}; use crate::server::Server; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; /// Main application builder for RustAPI @@ -331,7 +331,8 @@ impl RustApi { fn mount_auto_routes_grouped(mut self) -> Self { let routes = crate::auto_route::collect_auto_routes(); - let mut by_path: HashMap = HashMap::new(); + // Use BTreeMap for deterministic route registration order + let mut by_path: BTreeMap = BTreeMap::new(); for route in routes { let method_enum = match route.method { @@ -710,8 +711,12 @@ impl RustApi { let openapi_path = format!("{}/openapi.json", path); // Clone values for closures - let spec_json = - serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default(); + let spec_value = self.openapi_spec.to_json(); + let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| { + // Safe fallback if JSON serialization fails (though unlikely for Value) + tracing::error!("Failed to serialize OpenAPI spec: {}", e); + "{}".to_string() + }); let openapi_url = openapi_path.clone(); // Add OpenAPI JSON endpoint @@ -722,7 +727,13 @@ impl RustApi { .status(http::StatusCode::OK) .header(http::header::CONTENT_TYPE, "application/json") .body(crate::response::Body::from(json)) - .unwrap() + .unwrap_or_else(|e| { + tracing::error!("Failed to build response: {}", e); + http::Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body(crate::response::Body::from("Internal Server Error")) + .unwrap() + }) } }; @@ -815,8 +826,11 @@ impl RustApi { let expected_auth = format!("Basic {}", encoded); // Clone values for closures - let spec_json = - serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default(); + let spec_value = self.openapi_spec.to_json(); + let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| { + tracing::error!("Failed to serialize OpenAPI spec: {}", e); + "{}".to_string() + }); let openapi_url = openapi_path.clone(); let expected_auth_spec = expected_auth.clone(); let expected_auth_docs = expected_auth; @@ -834,7 +848,13 @@ impl RustApi { .status(http::StatusCode::OK) .header(http::header::CONTENT_TYPE, "application/json") .body(crate::response::Body::from(json)) - .unwrap() + .unwrap_or_else(|e| { + tracing::error!("Failed to build response: {}", e); + http::Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body(crate::response::Body::from("Internal Server Error")) + .unwrap() + }) }) as std::pin::Pin + Send>> }); diff --git a/crates/rustapi-core/tests/snapshot_test.rs b/crates/rustapi-core/tests/snapshot_test.rs new file mode 100644 index 0000000..f39fc1d --- /dev/null +++ b/crates/rustapi-core/tests/snapshot_test.rs @@ -0,0 +1,211 @@ +use rustapi_core::{get, RustApi}; +use rustapi_openapi::Schema; +use serde_json::json; + +#[derive(Schema)] +#[allow(dead_code)] +struct SnapshotUser { + id: i64, + username: String, +} + +#[tokio::test] +async fn test_openapi_snapshot() { + // 1. Setup App + let app = RustApi::new() + .openapi_info("Snapshot API", "1.0.0", Some("Test Description")) + .register_schema::() + .route("/users", get(|| async { "users" })) + .route("/users/{id}", get(|| async { "user" })); + + // 2. Generate Spec + let spec = app.openapi_spec(); + let json = spec.to_json(); + + // 3. Normalize/Pretty Print + let output = serde_json::to_string_pretty(&json).expect("Failed to serialize"); + + // 4. Expected Snapshot + let expected = json!({ + "openapi": "3.1.0", + "info": { + "title": "Snapshot API", + "version": "1.0.0", + "description": "Test Description" + }, + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "paths": { + "/users": { + "get": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ErrorBodySchema": { + "type": "object", + "properties": { + "error_type": { + "type": "string" + }, + "fields": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/FieldErrorSchema" + } + }, + "message": { + "type": "string" + } + }, + "required": [ + "error_type", + "message" + ] + }, + "ErrorSchema": { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorBodySchema" + }, + "request_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "error" + ] + }, + "FieldErrorSchema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "field", + "code", + "message" + ] + }, + "SnapshotUser": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "username" + ] + }, + "ValidationErrorBodySchema": { + "type": "object", + "properties": { + "error_type": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldErrorSchema" + } + }, + "message": { + "type": "string" + } + }, + "required": [ + "error_type", + "message", + "fields" + ] + }, + "ValidationErrorSchema": { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/ValidationErrorBodySchema" + } + }, + "required": [ + "error" + ] + } + } + } + }); + + // Assert structural equality first (better error messages) + assert_eq!(json, expected, "OpenAPI snapshot mismatch (structural)"); + + // Assert string equality (ensures serialization determinism) + let expected_str = serde_json::to_string_pretty(&expected).unwrap(); + assert_eq!( + output, expected_str, + "OpenAPI snapshot mismatch! output:\n{}\nexpected:\n{}", + output, expected_str + ); + + // Also ensure determinism: generate again and match + let json2 = app.openapi_spec().to_json(); + let output2 = serde_json::to_string_pretty(&json2).unwrap(); + assert_eq!(output, output2, "Nondeterministic output detected!"); +} diff --git a/crates/rustapi-macros/src/derive_schema.rs b/crates/rustapi-macros/src/derive_schema.rs index 7387d51..3820c47 100644 --- a/crates/rustapi-macros/src/derive_schema.rs +++ b/crates/rustapi-macros/src/derive_schema.rs @@ -6,6 +6,22 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { let name = input.ident; let generics = input.generics; let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let name_str = name.to_string(); + + // Generate name() impl body + let type_params: Vec = generics.type_params().map(|p| p.ident.clone()).collect(); + let name_impl_body = if type_params.is_empty() { + quote! { std::borrow::Cow::Borrowed(#name_str) } + } else { + quote! { + let mut n = String::from(#name_str); + #( + n.push('_'); + n.push_str(&<#type_params as ::rustapi_openapi::schema::RustApiSchema>::name()); + )* + std::borrow::Cow::Owned(n) + } + }; let (schema_impl, field_schemas_impl) = match input.data { Data::Struct(data) => impl_struct_schema_bodies(&name, data), @@ -22,9 +38,14 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { } fn component_name() -> Option<&'static str> { + // Keep backward compatibility, but this is less useful for generics now Some(stringify!(#name)) } + fn name() -> std::borrow::Cow<'static, str> { + #name_impl_body + } + fn field_schemas(ctx: &mut ::rustapi_openapi::schema::SchemaCtx) -> Option<::std::collections::BTreeMap> { #field_schemas_impl } @@ -32,9 +53,7 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { } } -fn impl_struct_schema_bodies(name: &Ident, data: DataStruct) -> (TokenStream, TokenStream) { - let name_str = name.to_string(); - +fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, TokenStream) { let mut field_logic = Vec::new(); let mut field_schemas_logic = Vec::new(); @@ -88,7 +107,9 @@ fn impl_struct_schema_bodies(name: &Ident, data: DataStruct) -> (TokenStream, To } let schema_body = quote! { - let name = #name_str; + let name_cow = ::name(); + let name = name_cow.as_ref(); + if let Some(_) = ctx.components.get(name) { return ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; } @@ -124,9 +145,7 @@ fn impl_struct_schema_bodies(name: &Ident, data: DataStruct) -> (TokenStream, To (schema_body, field_schemas_body) } -fn impl_enum_schema(name: &Ident, data: DataEnum) -> TokenStream { - let name_str = name.to_string(); - +fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { let is_string_enum = data .variants .iter() @@ -137,7 +156,9 @@ fn impl_enum_schema(name: &Ident, data: DataEnum) -> TokenStream { let push_variants = variants.iter().map(|v| quote! { #v.into() }); return quote! { - let name = #name_str; + let name_cow = ::name(); + let name = name_cow.as_ref(); + if let Some(_) = ctx.components.get(name) { return ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; } @@ -246,7 +267,9 @@ fn impl_enum_schema(name: &Ident, data: DataEnum) -> TokenStream { } quote! { - let name = #name_str; + let name_cow = ::name(); + let name = name_cow.as_ref(); + if let Some(_) = ctx.components.get(name) { return ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; } diff --git a/crates/rustapi-openapi/src/schema.rs b/crates/rustapi-openapi/src/schema.rs index 3a40b01..5539f3d 100644 --- a/crates/rustapi-openapi/src/schema.rs +++ b/crates/rustapi-openapi/src/schema.rs @@ -152,6 +152,12 @@ pub trait RustApiSchema { None } + /// Get a unique name for this type, including generic parameters. + /// Used for preventing name collisions in schema registry. + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Unknown") + } + /// Get field schemas if this type is a struct (for Query params extraction) fn field_schemas(_ctx: &mut SchemaCtx) -> Option> { None @@ -163,16 +169,25 @@ impl RustApiSchema for String { fn schema(_: &mut SchemaCtx) -> SchemaRef { SchemaRef::Schema(Box::new(JsonSchema2020::string())) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("String") + } } impl RustApiSchema for &str { fn schema(_: &mut SchemaCtx) -> SchemaRef { SchemaRef::Schema(Box::new(JsonSchema2020::string())) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("String") + } } impl RustApiSchema for bool { fn schema(_: &mut SchemaCtx) -> SchemaRef { SchemaRef::Schema(Box::new(JsonSchema2020::boolean())) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Boolean") + } } impl RustApiSchema for i32 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -180,6 +195,9 @@ impl RustApiSchema for i32 { s.format = Some("int32".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Int32") + } } impl RustApiSchema for i64 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -187,6 +205,9 @@ impl RustApiSchema for i64 { s.format = Some("int64".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Int64") + } } impl RustApiSchema for f64 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -194,6 +215,9 @@ impl RustApiSchema for f64 { s.format = Some("double".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Float64") + } } impl RustApiSchema for f32 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -201,6 +225,9 @@ impl RustApiSchema for f32 { s.format = Some("float".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Float32") + } } impl RustApiSchema for i8 { @@ -209,6 +236,9 @@ impl RustApiSchema for i8 { s.format = Some("int8".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Int8") + } } impl RustApiSchema for i16 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -216,6 +246,9 @@ impl RustApiSchema for i16 { s.format = Some("int16".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Int16") + } } impl RustApiSchema for isize { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -223,6 +256,9 @@ impl RustApiSchema for isize { s.format = Some("int64".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Int64") + } } impl RustApiSchema for u8 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -230,6 +266,9 @@ impl RustApiSchema for u8 { s.format = Some("uint8".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Uint8") + } } impl RustApiSchema for u16 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -237,6 +276,9 @@ impl RustApiSchema for u16 { s.format = Some("uint16".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Uint16") + } } impl RustApiSchema for u32 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -244,6 +286,9 @@ impl RustApiSchema for u32 { s.format = Some("uint32".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Uint32") + } } impl RustApiSchema for u64 { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -251,6 +296,9 @@ impl RustApiSchema for u64 { s.format = Some("uint64".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Uint64") + } } impl RustApiSchema for usize { fn schema(_: &mut SchemaCtx) -> SchemaRef { @@ -258,6 +306,9 @@ impl RustApiSchema for usize { s.format = Some("uint64".to_string()); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Uint64") + } } // Vec @@ -282,6 +333,9 @@ impl RustApiSchema for Vec { })), } } + fn name() -> std::borrow::Cow<'static, str> { + format!("Array_{}", T::name()).into() + } } // Option @@ -311,6 +365,9 @@ impl RustApiSchema for Option { _ => inner, } } + fn name() -> std::borrow::Cow<'static, str> { + format!("Option_{}", T::name()).into() + } } // HashMap @@ -332,6 +389,9 @@ impl RustApiSchema for std::collections::HashMap { s.additional_properties = Some(Box::new(add_prop)); SchemaRef::Schema(Box::new(s)) } + fn name() -> std::borrow::Cow<'static, str> { + format!("Map_{}", T::name()).into() + } } // Add empty SchemaTransformer for spec.rs usage diff --git a/crates/rustapi-openapi/src/spec.rs b/crates/rustapi-openapi/src/spec.rs index 7da9597..19ab6b5 100644 --- a/crates/rustapi-openapi/src/spec.rs +++ b/crates/rustapi-openapi/src/spec.rs @@ -1,7 +1,7 @@ //! OpenAPI 3.1 specification types use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use crate::schema::JsonSchema2020; pub use crate::schema::SchemaRef; @@ -115,7 +115,15 @@ impl OpenApiSpec { // Merge back into components let components = self.components.get_or_insert_with(Components::default); - components.schemas.extend(ctx.components); + for (name, schema) in ctx.components { + if let Some(existing) = components.schemas.get(&name) { + if existing != &schema { + panic!("Schema collision detected for component '{}'. Existing schema differs from new schema. This usually means two different types are mapped to the same component name. Please implement `RustApiSchema::name()` or alias the type.", name); + } + } else { + components.schemas.insert(name, schema); + } + } } pub fn server(mut self, server: Server) -> Self { @@ -135,6 +143,173 @@ impl OpenApiSpec { pub fn to_json(&self) -> serde_json::Value { serde_json::to_value(self).unwrap_or(serde_json::Value::Null) } + + /// Validate that all $ref references point to existing components. + /// Returns Ok(()) if valid, or a list of missing references. + pub fn validate_integrity(&self) -> Result<(), Vec> { + let mut defined_schemas = HashSet::new(); + if let Some(components) = &self.components { + for key in components.schemas.keys() { + defined_schemas.insert(format!("#/components/schemas/{}", key)); + } + } + + let mut missing_refs = Vec::new(); + + // Helper to check a single ref + let mut check_ref = |r: &str| { + if r.starts_with("#/components/schemas/") && !defined_schemas.contains(r) { + missing_refs.push(r.to_string()); + } + // Ignore other refs for now (e.g. external or non-schema refs) + }; + + // Visitor pattern to traverse the spec + let mut visit_schema = |schema: &SchemaRef| { + visit_schema_ref(schema, &mut check_ref); + }; + + // 1. Visit Paths + for path_item in self.paths.values() { + visit_path_item(path_item, &mut visit_schema); + } + + // 2. Visit Webhooks + for path_item in self.webhooks.values() { + visit_path_item(path_item, &mut visit_schema); + } + + // 3. Visit Components (including schemas referencing other schemas) + if let Some(components) = &self.components { + for schema in components.schemas.values() { + visit_json_schema(schema, &mut check_ref); + } + // TODO: Visit other components like parameters, headers, etc. if they can contain refs + } + + if missing_refs.is_empty() { + Ok(()) + } else { + // Deduplicate + missing_refs.sort(); + missing_refs.dedup(); + Err(missing_refs) + } + } +} + +fn visit_path_item(item: &PathItem, visit: &mut F) +where + F: FnMut(&SchemaRef), +{ + if let Some(op) = &item.get { + visit_operation(op, visit); + } + if let Some(op) = &item.put { + visit_operation(op, visit); + } + if let Some(op) = &item.post { + visit_operation(op, visit); + } + if let Some(op) = &item.delete { + visit_operation(op, visit); + } + if let Some(op) = &item.options { + visit_operation(op, visit); + } + if let Some(op) = &item.head { + visit_operation(op, visit); + } + if let Some(op) = &item.patch { + visit_operation(op, visit); + } + if let Some(op) = &item.trace { + visit_operation(op, visit); + } + + for param in &item.parameters { + if let Some(s) = ¶m.schema { + visit(s); + } + } +} + +fn visit_operation(op: &Operation, visit: &mut F) +where + F: FnMut(&SchemaRef), +{ + for param in &op.parameters { + if let Some(s) = ¶m.schema { + visit(s); + } + } + if let Some(body) = &op.request_body { + for media in body.content.values() { + if let Some(s) = &media.schema { + visit(s); + } + } + } + for resp in op.responses.values() { + for media in resp.content.values() { + if let Some(s) = &media.schema { + visit(s); + } + } + for header in resp.headers.values() { + if let Some(s) = &header.schema { + visit(s); + } + } + } +} + +fn visit_schema_ref(s: &SchemaRef, check: &mut F) +where + F: FnMut(&str), +{ + match s { + SchemaRef::Ref { reference } => check(reference), + SchemaRef::Schema(boxed) => visit_json_schema(boxed, check), + SchemaRef::Inline(_) => {} // Inline JSON value, assume safe or valid + } +} + +fn visit_json_schema(s: &JsonSchema2020, check: &mut F) +where + F: FnMut(&str), +{ + if let Some(r) = &s.reference { + check(r); + } + if let Some(items) = &s.items { + visit_json_schema(items, check); + } + if let Some(props) = &s.properties { + for p in props.values() { + visit_json_schema(p, check); + } + } + if let Some(crate::schema::AdditionalProperties::Schema(p)) = + &s.additional_properties.as_deref() + { + visit_json_schema(p, check); + } + if let Some(one_of) = &s.one_of { + for p in one_of { + visit_json_schema(p, check); + } + } + if let Some(any_of) = &s.any_of { + for p in any_of { + visit_json_schema(p, check); + } + } + if let Some(all_of) = &s.all_of { + for p in all_of { + visit_json_schema(p, check); + } + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/rustapi-openapi/src/tests.rs b/crates/rustapi-openapi/src/tests.rs index 7c6ffe5..54d853b 100644 --- a/crates/rustapi-openapi/src/tests.rs +++ b/crates/rustapi-openapi/src/tests.rs @@ -29,6 +29,7 @@ mod tests { } #[derive(Schema)] + #[allow(dead_code)] struct TestUser { id: i64, name: String, @@ -69,6 +70,7 @@ mod tests { } #[derive(Schema)] + #[allow(dead_code)] enum Status { Active, Inactive, @@ -90,6 +92,7 @@ mod tests { } #[derive(Schema)] + #[allow(dead_code)] enum Event { Created { id: i64 }, Deleted, @@ -104,4 +107,134 @@ mod tests { // Should be oneOf assert!(schema.one_of.is_some()); } + + #[derive(Schema)] + #[allow(dead_code)] + struct Wrapper { + value: T, + } + + #[test] + fn test_generic_collision() { + let mut spec = OpenApiSpec::new("Test", "1.0"); + + // Register Wrapper + spec.register_in_place::>(); + + // Register Wrapper + spec.register_in_place::>(); + + // Check components + let components = spec.components.as_ref().unwrap(); + + let has_string = components + .schemas + .keys() + .any(|k| k.contains("Wrapper") && k.contains("String")); + // Int32 because i32::name() returns "Int32" + let has_int32 = components + .schemas + .keys() + .any(|k| k.contains("Wrapper") && k.contains("Int32")); + + // If we only have "Wrapper", this will fail. + assert!(has_string, "Missing Wrapper_String component"); + assert!(has_int32, "Missing Wrapper_Int32 component"); + } + + struct CollisionA; + impl RustApiSchema for CollisionA { + fn schema(ctx: &mut SchemaCtx) -> SchemaRef { + ctx.components + .insert("Collision".to_string(), JsonSchema2020::string()); + SchemaRef::Ref { + reference: "#/components/schemas/Collision".to_string(), + } + } + fn name() -> std::borrow::Cow<'static, str> { + "Collision".into() + } + } + + struct CollisionB; + impl RustApiSchema for CollisionB { + fn schema(ctx: &mut SchemaCtx) -> SchemaRef { + ctx.components + .insert("Collision".to_string(), JsonSchema2020::integer()); + SchemaRef::Ref { + reference: "#/components/schemas/Collision".to_string(), + } + } + fn name() -> std::borrow::Cow<'static, str> { + "Collision".into() + } + } + + #[test] + #[should_panic(expected = "Schema collision detected")] + fn test_collision_detection() { + let mut spec = OpenApiSpec::new("Test", "1.0"); + spec.register_in_place::(); + spec.register_in_place::(); + } + + #[test] + fn test_ref_integrity_valid() { + let mut spec = OpenApiSpec::new("Test", "1.0"); + spec.register_in_place::(); + + // TestUser references primitive types which are inline, and itself (registered) + // Let's add a manual ref to ensure it works + use crate::spec::Operation; + use crate::spec::ResponseSpec; + + let mut op = Operation::new(); + op.responses + .insert("200".to_string(), ResponseSpec::default()); + + // This is valid because TestUser is registered + let param = crate::spec::Parameter { + name: "user".to_string(), + location: "query".to_string(), + required: false, + description: None, + deprecated: None, + schema: Some(SchemaRef::Ref { + reference: "#/components/schemas/TestUser".to_string(), + }), + }; + op.parameters.push(param); + + spec = spec.path("/user", "GET", op); + + assert!(spec.validate_integrity().is_ok()); + } + + #[test] + fn test_ref_integrity_invalid() { + let mut spec = OpenApiSpec::new("Test", "1.0"); + + use crate::spec::Operation; + + let mut op = Operation::new(); + let param = crate::spec::Parameter { + name: "user".to_string(), + location: "query".to_string(), + required: false, + description: None, + deprecated: None, + schema: Some(SchemaRef::Ref { + reference: "#/components/schemas/NonExistent".to_string(), + }), + }; + op.parameters.push(param); + + spec = spec.path("/user", "GET", op); + + let result = spec.validate_integrity(); + assert!(result.is_err()); + let missing = result.unwrap_err(); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0], "#/components/schemas/NonExistent"); + } }