Skip to content

Commit ea85cf1

Browse files
committed
Fix schema skip issue
1 parent b2e0814 commit ea85cf1

38 files changed

Lines changed: 1637 additions & 1006 deletions

File tree

crates/vespera/src/validated.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,12 @@ fn build_validation_response(report: &::garde::Report) -> Response {
200200
// to axum as raw bytes (content-type is overridden to
201201
// application/json below regardless). Byte-identical to the previous
202202
// `to_string` body.
203-
let body = ::serde_json::to_vec(&ValidationEnvelope { report })
204-
.expect("serializing the 422 validation envelope is infallible");
203+
// Serializing the envelope is practically infallible (no I/O, string
204+
// keys), but this is a request-time boundary: on the unreachable failure
205+
// path emit a minimal valid 422 envelope rather than panicking.
206+
let body = ::serde_json::to_vec(&ValidationEnvelope { report }).unwrap_or_else(|_| {
207+
br#"{"errors":[{"path":"","message":"request validation failed"}]}"#.to_vec()
208+
});
205209

206210
let mut response = (StatusCode::UNPROCESSABLE_ENTITY, body).into_response();
207211
response.headers_mut().insert(

crates/vespera/tests/multipart_wire.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ use ::vespera_inprocess::{dispatch_from_bytes, register_app};
2727
#[allow(dead_code)]
2828
struct UploadReq {
2929
name: String,
30+
// This round-trip test intentionally accepts any size (it exercises the
31+
// 256 KiB tempfile path with the body limit disabled), so it opts out of
32+
// the now-mandatory file-field cap explicitly rather than inheriting one.
33+
#[form_data(limit = "unlimited")]
3034
file: FieldData<NamedTempFile>,
3135
}
3236

crates/vespera_core/src/openapi.rs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -188,15 +188,7 @@ impl OpenApi {
188188
if let Some(other_components) = other.components
189189
&& has_any_component_map(&other_components)
190190
{
191-
let self_components = self.components.get_or_insert(Components {
192-
schemas: None,
193-
responses: None,
194-
parameters: None,
195-
examples: None,
196-
request_bodies: None,
197-
headers: None,
198-
security_schemes: None,
199-
});
191+
let self_components = self.components.get_or_insert_with(Components::default);
200192

201193
merge_component_map(&mut self_components.schemas, other_components.schemas);
202194
merge_component_map(&mut self_components.responses, other_components.responses);
@@ -229,15 +221,16 @@ impl OpenApi {
229221
// with the existing tag names and grows as incoming tags are appended,
230222
// so an incoming tag is kept only when its name collides with neither
231223
// an existing tag nor an already-appended incoming one (first-wins,
232-
// incoming insertion order preserved). Tag merging runs at compile
233-
// time over a handful of tags, so owning the names (one clone each)
234-
// is cheaper to read than the prior borrow-juggling two-pass flag Vec.
224+
// incoming insertion order preserved). A name is cloned only when the
225+
// tag is actually kept — a duplicate is detected by borrow and skipped
226+
// without cloning.
235227
if let Some(other_tags) = other.tags {
236228
let self_tags = self.tags.get_or_insert_with(Vec::new);
237229
let mut seen: std::collections::HashSet<String> =
238230
self_tags.iter().map(|tag| tag.name.clone()).collect();
239231
for tag in other_tags {
240-
if seen.insert(tag.name.clone()) {
232+
if !seen.contains(tag.name.as_str()) {
233+
seen.insert(tag.name.clone());
241234
self_tags.push(tag);
242235
}
243236
}

crates/vespera_core/src/schema.rs

Lines changed: 133 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,20 @@
33
use serde::{Deserialize, Serialize};
44
use std::collections::BTreeMap;
55

6-
/// Schema reference or inline schema
7-
#[derive(Debug, Clone, Serialize, Deserialize)]
6+
/// Schema reference or inline schema.
7+
///
8+
/// Serializes untagged — a bare `{"$ref": ...}` object for
9+
/// [`SchemaRef::Ref`], the schema object for [`SchemaRef::Inline`].
10+
///
11+
/// Deserialization is a hand-written impl rather than
12+
/// `#[serde(untagged)]`: an untagged `Ref`-first enum greedily matched
13+
/// **any** object carrying a `$ref` key and silently dropped its
14+
/// siblings (e.g. a nullable reference's `"nullable": true`). The
15+
/// custom impl treats only a *pure* `{"$ref": <string>}` object as a
16+
/// reference; a `$ref` accompanied by any sibling keyword
17+
/// (`nullable`, `description`, …) is an inline [`Schema`], so those
18+
/// siblings survive the round-trip instead of being discarded.
19+
#[derive(Debug, Clone, Serialize)]
820
#[serde(untagged)]
921
pub enum SchemaRef {
1022
/// Schema reference (e.g., "#/components/schemas/User")
@@ -13,6 +25,31 @@ pub enum SchemaRef {
1325
Inline(Box<Schema>),
1426
}
1527

28+
impl<'de> Deserialize<'de> for SchemaRef {
29+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
30+
where
31+
D: serde::Deserializer<'de>,
32+
{
33+
use serde::de::Error as _;
34+
// OpenAPI is always JSON; buffer the node so a *pure* reference
35+
// can be distinguished from a `$ref` carrying sibling keywords.
36+
let value = serde_json::Value::deserialize(deserializer)?;
37+
// Pure reference: an object whose ONLY key is `$ref` with a string
38+
// value. A `$ref` with any sibling (`nullable`, `description`, …)
39+
// is an inline schema, so the siblings are preserved instead of
40+
// being dropped by the prior untagged `Ref`-first match.
41+
if let serde_json::Value::Object(map) = &value
42+
&& map.len() == 1
43+
&& let Some(serde_json::Value::String(ref_path)) = map.get("$ref")
44+
{
45+
return Ok(Self::Ref(Reference::new(ref_path.clone())));
46+
}
47+
serde_json::from_value::<Schema>(value)
48+
.map(|schema| Self::Inline(Box::new(schema)))
49+
.map_err(D::Error::custom)
50+
}
51+
}
52+
1653
/// Reference definition
1754
#[derive(Debug, Clone, Serialize, Deserialize)]
1855
pub struct Reference {
@@ -274,73 +311,40 @@ pub struct Schema {
274311
}
275312

276313
impl Schema {
277-
/// Create a new schema
314+
/// Create a new schema of the given type.
315+
///
316+
/// Every other field starts at its [`Default`] (`None`/empty), so a newly
317+
/// added `Schema` field is auto-defaulted here instead of having to be
318+
/// appended to a ~40-field manual initializer that drifts out of sync.
278319
#[must_use]
279-
pub const fn new(schema_type: SchemaType) -> Self {
320+
pub fn new(schema_type: SchemaType) -> Self {
280321
Self {
281-
ref_path: None,
282322
schema_type: Some(schema_type),
283-
format: None,
284-
title: None,
285-
description: None,
286-
default: None,
287-
example: None,
288-
examples: None,
289-
minimum: None,
290-
maximum: None,
291-
exclusive_minimum: None,
292-
exclusive_maximum: None,
293-
multiple_of: None,
294-
min_length: None,
295-
max_length: None,
296-
pattern: None,
297-
items: None,
298-
prefix_items: None,
299-
min_items: None,
300-
max_items: None,
301-
unique_items: None,
302-
properties: None,
303-
required: None,
304-
additional_properties: None,
305-
min_properties: None,
306-
max_properties: None,
307-
r#enum: None,
308-
all_of: None,
309-
any_of: None,
310-
one_of: None,
311-
not: None,
312-
discriminator: None,
313-
nullable: None,
314-
read_only: None,
315-
write_only: None,
316-
external_docs: None,
317-
defs: None,
318-
dynamic_anchor: None,
319-
dynamic_ref: None,
323+
..Self::default()
320324
}
321325
}
322326

323327
/// Create a string schema
324328
#[must_use]
325-
pub const fn string() -> Self {
329+
pub fn string() -> Self {
326330
Self::new(SchemaType::String)
327331
}
328332

329333
/// Create an integer schema
330334
#[must_use]
331-
pub const fn integer() -> Self {
335+
pub fn integer() -> Self {
332336
Self::new(SchemaType::Integer)
333337
}
334338

335339
/// Create a number schema
336340
#[must_use]
337-
pub const fn number() -> Self {
341+
pub fn number() -> Self {
338342
Self::new(SchemaType::Number)
339343
}
340344

341345
/// Create a boolean schema
342346
#[must_use]
343-
pub const fn boolean() -> Self {
347+
pub fn boolean() -> Self {
344348
Self::new(SchemaType::Boolean)
345349
}
346350

@@ -381,6 +385,26 @@ impl Schema {
381385
..Self::new(SchemaType::Object)
382386
}
383387
}
388+
389+
/// Reconstruct a [`Schema`] from a compile-time-serialized JSON spec.
390+
///
391+
/// This is the bridge the `schema!` proc-macro uses to emit a runtime
392+
/// `Schema` value that is **identical** to the one the OpenAPI
393+
/// generator produces for the same type: the macro builds the schema
394+
/// through the shared `parse_struct_to_schema` path, serializes it to
395+
/// JSON at compile time, and emits a call to this constructor — so the
396+
/// `schema!` result can never drift from the documented component
397+
/// schema (required-by-nullability, doc descriptions,
398+
/// flatten/transparent, field constraints, `$ref` references).
399+
///
400+
/// The input is always valid JSON (the macro just serialized it via
401+
/// `serde_json`), so a parse failure is unreachable in practice; it
402+
/// degrades to [`Schema::default`] rather than panicking inside
403+
/// generated user code.
404+
#[must_use]
405+
pub fn from_compiled_json(json: &str) -> Self {
406+
serde_json::from_str(json).unwrap_or_default()
407+
}
384408
}
385409

386410
/// External documentation reference
@@ -409,7 +433,7 @@ pub struct Discriminator {
409433
}
410434

411435
/// `OpenAPI` Components (reusable components)
412-
#[derive(Debug, Clone, Serialize, Deserialize)]
436+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
413437
#[serde(rename_all = "camelCase")]
414438
pub struct Components {
415439
/// Schema definitions
@@ -691,4 +715,66 @@ mod tests {
691715
"a nullable reference must not also emit a type: {json}"
692716
);
693717
}
718+
719+
// ── SchemaRef: $ref-sibling preservation ─────────────────────────
720+
//
721+
// The prior `#[serde(untagged)]` `Ref`-first enum greedily matched
722+
// ANY object with a `$ref` key and silently dropped its siblings
723+
// (e.g. a nullable reference's `"nullable": true`). The custom
724+
// `Deserialize` treats only a *pure* `{"$ref": <string>}` as a
725+
// reference; a `$ref` with any sibling becomes an inline `Schema`
726+
// so the siblings round-trip intact.
727+
728+
#[test]
729+
fn schema_ref_pure_ref_deserializes_as_ref() {
730+
let v: SchemaRef =
731+
serde_json::from_str(r##"{"$ref":"#/components/schemas/User"}"##).unwrap();
732+
match v {
733+
SchemaRef::Ref(r) => assert_eq!(r.ref_path, "#/components/schemas/User"),
734+
SchemaRef::Inline(_) => panic!("a pure $ref must deserialize as SchemaRef::Ref"),
735+
}
736+
}
737+
738+
#[test]
739+
fn schema_ref_with_nullable_sibling_preserves_fields() {
740+
let v: SchemaRef =
741+
serde_json::from_str(r##"{"$ref":"#/components/schemas/User","nullable":true}"##)
742+
.unwrap();
743+
match v {
744+
SchemaRef::Inline(schema) => {
745+
assert_eq!(
746+
schema.ref_path.as_deref(),
747+
Some("#/components/schemas/User"),
748+
"the $ref must survive as an inline ref_path"
749+
);
750+
assert_eq!(
751+
schema.nullable,
752+
Some(true),
753+
"the nullable sibling must not be dropped"
754+
);
755+
}
756+
SchemaRef::Ref(_) => panic!("$ref with a sibling must not be matched as a bare Ref"),
757+
}
758+
}
759+
760+
#[test]
761+
fn schema_ref_inline_object_deserializes_as_inline() {
762+
let v: SchemaRef = serde_json::from_str(r#"{"type":"string"}"#).unwrap();
763+
assert!(matches!(v, SchemaRef::Inline(_)));
764+
}
765+
766+
#[test]
767+
fn schema_ref_nullable_reference_roundtrips() {
768+
// Build → serialize → deserialize must keep BOTH `$ref` and `nullable`.
769+
let original = Schema::nullable_reference("#/components/schemas/User".to_owned());
770+
let json = serde_json::to_string(&SchemaRef::Inline(Box::new(original))).unwrap();
771+
let back: SchemaRef = serde_json::from_str(&json).unwrap();
772+
match back {
773+
SchemaRef::Inline(s) => {
774+
assert_eq!(s.ref_path.as_deref(), Some("#/components/schemas/User"));
775+
assert_eq!(s.nullable, Some(true));
776+
}
777+
SchemaRef::Ref(_) => panic!("a nullable reference must round-trip as inline"),
778+
}
779+
}
694780
}

crates/vespera_inprocess/src/wire.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ mod tests {
142142
// empty headers object + duplicate header NAMES preserved
143143
br#"{"v":1,"method":"GET","path":"/p","headers":{}}"#,
144144
br#"{"v":1,"method":"GET","path":"/p","headers":{"x-a":"1","x-a":"2"}}"#,
145+
// VALID but complex values under an UNKNOWN key — the strict
146+
// skip must still ACCEPT every JSON-legal form (negative /
147+
// float / exponent numbers, escaped strings, nested arrays and
148+
// objects, the three literals) so forward-compat fields aren't
149+
// over-rejected.
150+
br#"{"v":1,"method":"GET","path":"/p","a":-3.14e10,"b":"esc\"d\n","c":[true,null,{"x":1}],"d":0}"#,
145151
];
146152
for case in cases {
147153
match (parse_wire_header(case), parse_wire_header_serde(case)) {
@@ -177,6 +183,20 @@ mod tests {
177183
br#"{"v":1,"method":"GET","path":"/p","headers":{"x":1}}"#, // header value not string
178184
br#"{"v":1,"method":"GET","path":"/p","app":7}"#, // app not string/null
179185
br#"{"v":1,"method":"GET","path":"/p","headers":[]}"#, // headers not object
186+
// Malformed values under UNKNOWN keys must still be rejected
187+
// (the skip path validates the full JSON grammar, matching
188+
// serde_json — not the prior permissive skip that accepted them).
189+
br#"{"v":1,"method":"GET","path":"/p","x":"\q"}"#, // invalid string escape
190+
b"{\"v\":1,\"method\":\"GET\",\"path\":\"/p\",\"x\":\"\x01\"}", // unescaped control char
191+
br#"{"v":1,"method":"GET","path":"/p","x":tru}"#, // truncated literal
192+
br#"{"v":1,"method":"GET","path":"/p","x":nul}"#, // truncated null
193+
br#"{"v":1,"method":"GET","path":"/p","x":1e+}"#, // exponent without digit
194+
br#"{"v":1,"method":"GET","path":"/p","x":1.}"#, // fraction without digit
195+
br#"{"v":1,"method":"GET","path":"/p","x":01}"#, // leading zero
196+
br#"{"v":1,"method":"GET","path":"/p","x":[}"#, // mismatched container open
197+
br#"{"v":1,"method":"GET","path":"/p","x":[1,2}"#, // array closed by '}'
198+
br#"{"v":1,"method":"GET","path":"/p","x":{"a":1,}}"#, // trailing comma in object
199+
br#"{"v":01,"method":"GET","path":"/p"}"#, // leading zero in `v`
180200
];
181201
for case in bad {
182202
assert!(

0 commit comments

Comments
 (0)