Skip to content

Commit 73d8fbc

Browse files
authored
OpenAPI: per-status response config + RFC 7807 problem+json support (#162)
* added status code config for open api * Add produces_problem() / produces_problem_example() for OpenAPI (RFC 7807) Adds two new methods on OpenApiRouteConfig, gated under the problem-details feature, that document problem+json responses in the generated OpenAPI spec: cfg.produces_problem(400) cfg.produces_problem_example(422, Problem::new(422).with_detail("...")) Both use application/problem+json as the content type (RFC 7807). produces_problem() derives a canonical example (type, title, status) from the HTTP status code's reason phrase. The feature is forwarded from volga's problem-details flag to volga-open-api via the optional dep syntax, so it activates automatically when both openapi and problem-details are enabled. * updated CHANGELOG
1 parent 35679b6 commit 73d8fbc

File tree

15 files changed

+472
-276
lines changed

15 files changed

+472
-276
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## Unreleased
9+
10+
### Added
11+
* Per-status-code OpenAPI response config: `produces_*` methods now accept a status code, `IntoStatusCode` trait (supports `u16`, `u32`, `i32`, `http::StatusCode`) (#162)
12+
* OpenAPI `produces_problem()` and `produces_problem_example()` for `application/problem+json` responses, gated on `problem-details` feature (#162)
13+
814
## 0.8.4
915

1016
### Added

examples/open_api/src/main.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ async fn main() -> std::io::Result<()> {
3737
api.map_get("/{name}", async |name: String| ok!(fmt: "Hello {name}"));
3838
api.map_get("/{name}/{age:integer}", async |Path((_name, _age)): Path<(String, u32)>| {});
3939
api.map_get("/named/{name}/{age}", async |path: NamedPath<Payload>| ok!(path.into_inner()))
40-
.open_api(|cfg| cfg.produces_json::<Payload>());
40+
.open_api(|cfg| cfg
41+
.produces_json::<Payload>(200)
42+
.produces_no_schema(400));
4143
});
4244

4345
app.group("/file", |api| {
@@ -60,12 +62,12 @@ async fn main() -> std::io::Result<()> {
6062
app.map_put("/form", async |payload: Form<Payload>| payload)
6163
.open_api(|cfg| cfg
6264
.with_doc("v2")
63-
.produces_form::<Payload>());
64-
65+
.produces_form::<Payload>(200u16));
66+
6567
app.map_post("/json", async |payload: Json<Payload>| payload)
6668
.open_api(|cfg| cfg
6769
.with_doc("v2")
68-
.produces_json_example(Payload { name: "John".into(), age: 30 }));
70+
.produces_json_example(200u16, Payload { name: "John".into(), age: 30 }));
6971

7072
app.run().await
7173
}

volga-dev-cert/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volga-dev-cert"
3-
version = "0.8.4"
3+
version = "0.8.5"
44
readme = "README.md"
55
description = "A Rust library for generating self-signed TLS certificates for local development."
66
edition.workspace = true
@@ -17,7 +17,7 @@ keywords = ["volga", "server", "https", "tls"]
1717
rcgen = "0.14.7"
1818

1919
[dev-dependencies]
20-
serial_test = "3.3.1"
20+
serial_test = "3.4.0"
2121

2222
[lints]
2323
workspace = true

volga-di/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volga-di"
3-
version = "0.8.4"
3+
version = "0.8.5"
44
readme = "README.md"
55
description = "Dependency Injection tools for Volga Web Framework"
66
edition.workspace = true

volga-macros/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volga-macros"
3-
version = "0.8.4"
3+
version = "0.8.5"
44
readme = "README.md"
55
description = "Macros for Volga Web Framework"
66
edition.workspace = true

volga-open-api/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volga-open-api"
3-
version = "0.8.4"
3+
version = "0.8.5"
44
readme = "README.md"
55
description = "Volga Open API Integration"
66
edition.workspace = true
@@ -21,5 +21,8 @@ serde_json = "1.0.149"
2121
serde_urlencoded = "0.7.1"
2222
serde = { version = "1.0.228", features = ["derive"] }
2323

24+
[features]
25+
problem-details = []
26+
2427
[lints]
2528
workspace = true

volga-open-api/src/doc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ mod tests {
6969
fn prune_unreferenced_components_keeps_only_reachable_transitive_refs() {
7070
let mut op = OpenApiOperation::default();
7171
op.set_response_body(
72+
200,
7273
OpenApiSchema::reference("User"),
7374
None,
7475
"application/json",

volga-open-api/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ mod ui;
1818

1919
pub use {
2020
config::{OpenApiConfig, OpenApiSpec},
21-
route::OpenApiRouteConfig,
21+
route::{OpenApiRouteConfig, IntoStatusCode},
2222
registry::OpenApiRegistry,
2323
doc::OpenApiDocument,
2424
ui::ui_html,

volga-open-api/src/op.rs

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,38 @@ impl OpenApiOperation {
9898

9999
pub(super) fn set_response_body(
100100
&mut self,
101+
status: u16,
101102
schema: OpenApiSchema,
102103
example: Option<Value>,
103104
content_type: &str,
104105
) {
106+
let description = http::StatusCode::from_u16(status)
107+
.ok()
108+
.and_then(|s| s.canonical_reason())
109+
.unwrap_or("Response")
110+
.to_string();
105111
let response = self
106112
.responses
107-
.entry("200".to_string())
108-
.or_insert_with(|| OpenApiResponse {
109-
description: "OK".to_string(),
110-
content: None,
111-
});
113+
.entry(status.to_string())
114+
.or_insert_with(|| OpenApiResponse { description, content: None });
112115
response.content = Some(media_content(content_type, schema, example));
113116
}
114117

115-
pub(super) fn clear_response_body(&mut self) {
116-
if let Some(resp) = self.responses.get_mut("200") {
117-
resp.content = None;
118-
}
118+
pub(super) fn clear_response_body(&mut self, status: u16) {
119+
let description = http::StatusCode::from_u16(status)
120+
.ok()
121+
.and_then(|s| s.canonical_reason())
122+
.unwrap_or("Response")
123+
.to_string();
124+
let response = self
125+
.responses
126+
.entry(status.to_string())
127+
.or_insert_with(|| OpenApiResponse { description, content: None });
128+
response.content = None;
129+
}
130+
131+
pub(super) fn clear_all_responses(&mut self) {
132+
self.responses.clear();
119133
}
120134

121135
pub(super) fn collect_component_refs(&self, out: &mut BTreeSet<String>) {
@@ -184,6 +198,7 @@ mod tests {
184198
"text/plain",
185199
);
186200
operation.set_response_body(
201+
200,
187202
OpenApiSchema::integer(),
188203
Some(json!(42)),
189204
"application/custom",
@@ -209,6 +224,42 @@ mod tests {
209224
);
210225
}
211226

227+
#[test]
228+
fn set_response_body_non_200_status() {
229+
let mut operation = OpenApiOperation::default();
230+
operation.clear_all_responses();
231+
operation.set_response_body(
232+
201,
233+
OpenApiSchema::object(),
234+
None,
235+
"application/json",
236+
);
237+
238+
let json = serde_json::to_value(operation).expect("serialize");
239+
assert!(json["responses"].get("200").is_none());
240+
assert!(json["responses"]["201"]["content"].get("application/json").is_some());
241+
assert_eq!(json["responses"]["201"]["description"], "Created");
242+
}
243+
244+
#[test]
245+
fn clear_response_body_removes_content_for_status() {
246+
let mut operation = OpenApiOperation::default();
247+
operation.set_response_body(200, OpenApiSchema::string(), None, "text/plain");
248+
operation.clear_response_body(200);
249+
250+
let json = serde_json::to_value(operation).expect("serialize");
251+
assert!(json["responses"]["200"].get("content").is_none());
252+
}
253+
254+
#[test]
255+
fn clear_all_responses_empties_map() {
256+
let mut operation = OpenApiOperation::default();
257+
operation.clear_all_responses();
258+
259+
let json = serde_json::to_value(operation).expect("serialize");
260+
assert!(json["responses"].as_object().expect("responses object").is_empty());
261+
}
262+
212263
#[test]
213264
fn collect_component_refs_includes_params_request_and_response() {
214265
let mut operation = OpenApiOperation {
@@ -217,7 +268,7 @@ mod tests {
217268
location: "path".to_string(),
218269
required: true,
219270
schema: OpenApiSchema::reference("PathParam"),
220-
}]),
271+
}]),
221272
..Default::default()
222273
};
223274

@@ -227,6 +278,7 @@ mod tests {
227278
"application/json",
228279
);
229280
operation.set_response_body(
281+
200,
230282
OpenApiSchema::reference("User"),
231283
None,
232284
"application/json",

volga-open-api/src/registry.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,13 @@ mod tests {
364364
let registry = OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
365365

366366
let first = OpenApiRouteConfig::default().with_response_schema(
367+
200u16,
367368
crate::schema::OpenApiSchema::object()
368369
.with_title("User")
369370
.with_property("name", crate::schema::OpenApiSchema::string()),
370371
);
371372
let second = OpenApiRouteConfig::default().with_response_schema(
373+
200u16,
372374
crate::schema::OpenApiSchema::object()
373375
.with_title("User")
374376
.with_property("id", crate::schema::OpenApiSchema::integer()),

0 commit comments

Comments
 (0)