Skip to content

Commit 61e02eb

Browse files
authored
Merge pull request #79 from Tuntii/openapi-stabilization-16972392054356650202
Stabilize OpenAPI Generation and Docs
2 parents 5e2f0fe + 9391f57 commit 61e02eb

7 files changed

Lines changed: 669 additions & 19 deletions

File tree

check_diff.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
3+
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"}}}}}}"""
4+
5+
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"}}}}}}"""
6+
7+
left = json.loads(left_str)
8+
right = json.loads(right_str)
9+
10+
def compare(path, l, r):
11+
if l != r:
12+
print(f"Difference at {path}: {l} != {r}")
13+
if isinstance(l, dict) and isinstance(r, dict):
14+
for k in l:
15+
if k in r:
16+
compare(f"{path}.{k}", l[k], r[k])
17+
else:
18+
print(f"Missing key in right: {path}.{k}")
19+
for k in r:
20+
if k not in l:
21+
print(f"Missing key in left: {path}.{k}")
22+
elif isinstance(l, list) and isinstance(r, list):
23+
if len(l) != len(r):
24+
print(f"Length mismatch at {path}")
25+
for i in range(min(len(l), len(r))):
26+
compare(f"{path}[{i}]", l[i], r[i])
27+
28+
compare("root", left, right)

crates/rustapi-core/src/app.rs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BOD
66
use crate::response::IntoResponse;
77
use crate::router::{MethodRouter, Router};
88
use crate::server::Server;
9-
use std::collections::{BTreeMap, HashMap};
9+
use std::collections::BTreeMap;
1010
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
1111

1212
/// Main application builder for RustAPI
@@ -331,7 +331,8 @@ impl RustApi {
331331

332332
fn mount_auto_routes_grouped(mut self) -> Self {
333333
let routes = crate::auto_route::collect_auto_routes();
334-
let mut by_path: HashMap<String, MethodRouter> = HashMap::new();
334+
// Use BTreeMap for deterministic route registration order
335+
let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
335336

336337
for route in routes {
337338
let method_enum = match route.method {
@@ -710,8 +711,12 @@ impl RustApi {
710711
let openapi_path = format!("{}/openapi.json", path);
711712

712713
// Clone values for closures
713-
let spec_json =
714-
serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
714+
let spec_value = self.openapi_spec.to_json();
715+
let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
716+
// Safe fallback if JSON serialization fails (though unlikely for Value)
717+
tracing::error!("Failed to serialize OpenAPI spec: {}", e);
718+
"{}".to_string()
719+
});
715720
let openapi_url = openapi_path.clone();
716721

717722
// Add OpenAPI JSON endpoint
@@ -722,7 +727,13 @@ impl RustApi {
722727
.status(http::StatusCode::OK)
723728
.header(http::header::CONTENT_TYPE, "application/json")
724729
.body(crate::response::Body::from(json))
725-
.unwrap()
730+
.unwrap_or_else(|e| {
731+
tracing::error!("Failed to build response: {}", e);
732+
http::Response::builder()
733+
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
734+
.body(crate::response::Body::from("Internal Server Error"))
735+
.unwrap()
736+
})
726737
}
727738
};
728739

@@ -815,8 +826,11 @@ impl RustApi {
815826
let expected_auth = format!("Basic {}", encoded);
816827

817828
// Clone values for closures
818-
let spec_json =
819-
serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
829+
let spec_value = self.openapi_spec.to_json();
830+
let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
831+
tracing::error!("Failed to serialize OpenAPI spec: {}", e);
832+
"{}".to_string()
833+
});
820834
let openapi_url = openapi_path.clone();
821835
let expected_auth_spec = expected_auth.clone();
822836
let expected_auth_docs = expected_auth;
@@ -834,7 +848,13 @@ impl RustApi {
834848
.status(http::StatusCode::OK)
835849
.header(http::header::CONTENT_TYPE, "application/json")
836850
.body(crate::response::Body::from(json))
837-
.unwrap()
851+
.unwrap_or_else(|e| {
852+
tracing::error!("Failed to build response: {}", e);
853+
http::Response::builder()
854+
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
855+
.body(crate::response::Body::from("Internal Server Error"))
856+
.unwrap()
857+
})
838858
})
839859
as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
840860
});
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
use rustapi_core::{get, RustApi};
2+
use rustapi_openapi::Schema;
3+
use serde_json::json;
4+
5+
#[derive(Schema)]
6+
#[allow(dead_code)]
7+
struct SnapshotUser {
8+
id: i64,
9+
username: String,
10+
}
11+
12+
#[tokio::test]
13+
async fn test_openapi_snapshot() {
14+
// 1. Setup App
15+
let app = RustApi::new()
16+
.openapi_info("Snapshot API", "1.0.0", Some("Test Description"))
17+
.register_schema::<SnapshotUser>()
18+
.route("/users", get(|| async { "users" }))
19+
.route("/users/{id}", get(|| async { "user" }));
20+
21+
// 2. Generate Spec
22+
let spec = app.openapi_spec();
23+
let json = spec.to_json();
24+
25+
// 3. Normalize/Pretty Print
26+
let output = serde_json::to_string_pretty(&json).expect("Failed to serialize");
27+
28+
// 4. Expected Snapshot
29+
let expected = json!({
30+
"openapi": "3.1.0",
31+
"info": {
32+
"title": "Snapshot API",
33+
"version": "1.0.0",
34+
"description": "Test Description"
35+
},
36+
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
37+
"paths": {
38+
"/users": {
39+
"get": {
40+
"responses": {
41+
"200": {
42+
"description": "Successful response",
43+
"content": {
44+
"text/plain": {
45+
"schema": {
46+
"type": "string"
47+
}
48+
}
49+
}
50+
}
51+
}
52+
}
53+
},
54+
"/users/{id}": {
55+
"get": {
56+
"parameters": [
57+
{
58+
"name": "id",
59+
"in": "path",
60+
"required": true,
61+
"schema": {
62+
"type": "string"
63+
}
64+
}
65+
],
66+
"responses": {
67+
"200": {
68+
"description": "Successful response",
69+
"content": {
70+
"text/plain": {
71+
"schema": {
72+
"type": "string"
73+
}
74+
}
75+
}
76+
}
77+
}
78+
}
79+
}
80+
},
81+
"components": {
82+
"schemas": {
83+
"ErrorBodySchema": {
84+
"type": "object",
85+
"properties": {
86+
"error_type": {
87+
"type": "string"
88+
},
89+
"fields": {
90+
"type": [
91+
"array",
92+
"null"
93+
],
94+
"items": {
95+
"$ref": "#/components/schemas/FieldErrorSchema"
96+
}
97+
},
98+
"message": {
99+
"type": "string"
100+
}
101+
},
102+
"required": [
103+
"error_type",
104+
"message"
105+
]
106+
},
107+
"ErrorSchema": {
108+
"type": "object",
109+
"properties": {
110+
"error": {
111+
"$ref": "#/components/schemas/ErrorBodySchema"
112+
},
113+
"request_id": {
114+
"type": [
115+
"string",
116+
"null"
117+
]
118+
}
119+
},
120+
"required": [
121+
"error"
122+
]
123+
},
124+
"FieldErrorSchema": {
125+
"type": "object",
126+
"properties": {
127+
"code": {
128+
"type": "string"
129+
},
130+
"field": {
131+
"type": "string"
132+
},
133+
"message": {
134+
"type": "string"
135+
}
136+
},
137+
"required": [
138+
"field",
139+
"code",
140+
"message"
141+
]
142+
},
143+
"SnapshotUser": {
144+
"type": "object",
145+
"properties": {
146+
"id": {
147+
"type": "integer",
148+
"format": "int64"
149+
},
150+
"username": {
151+
"type": "string"
152+
}
153+
},
154+
"required": [
155+
"id",
156+
"username"
157+
]
158+
},
159+
"ValidationErrorBodySchema": {
160+
"type": "object",
161+
"properties": {
162+
"error_type": {
163+
"type": "string"
164+
},
165+
"fields": {
166+
"type": "array",
167+
"items": {
168+
"$ref": "#/components/schemas/FieldErrorSchema"
169+
}
170+
},
171+
"message": {
172+
"type": "string"
173+
}
174+
},
175+
"required": [
176+
"error_type",
177+
"message",
178+
"fields"
179+
]
180+
},
181+
"ValidationErrorSchema": {
182+
"type": "object",
183+
"properties": {
184+
"error": {
185+
"$ref": "#/components/schemas/ValidationErrorBodySchema"
186+
}
187+
},
188+
"required": [
189+
"error"
190+
]
191+
}
192+
}
193+
}
194+
});
195+
196+
// Assert structural equality first (better error messages)
197+
assert_eq!(json, expected, "OpenAPI snapshot mismatch (structural)");
198+
199+
// Assert string equality (ensures serialization determinism)
200+
let expected_str = serde_json::to_string_pretty(&expected).unwrap();
201+
assert_eq!(
202+
output, expected_str,
203+
"OpenAPI snapshot mismatch! output:\n{}\nexpected:\n{}",
204+
output, expected_str
205+
);
206+
207+
// Also ensure determinism: generate again and match
208+
let json2 = app.openapi_spec().to_json();
209+
let output2 = serde_json::to_string_pretty(&json2).unwrap();
210+
assert_eq!(output, output2, "Nondeterministic output detected!");
211+
}

0 commit comments

Comments
 (0)