Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions check_diff.py
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 28 additions & 8 deletions crates/rustapi-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, MethodRouter> = HashMap::new();
// Use BTreeMap for deterministic route registration order
let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();

for route in routes {
let method_enum = match route.method {
Expand Down Expand Up @@ -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
Expand All @@ -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()
})
}
};

Expand Down Expand Up @@ -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;
Expand All @@ -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<Box<dyn std::future::Future<Output = crate::Response> + Send>>
});
Expand Down
211 changes: 211 additions & 0 deletions crates/rustapi-core/tests/snapshot_test.rs
Original file line number Diff line number Diff line change
@@ -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::<SnapshotUser>()
.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!");
}
Loading
Loading