Skip to content

Commit 0db56dc

Browse files
committed
feat(openapi): richer info (description/termsOfService/contact/license) + top-level servers on App::openapi (FastAPI parity; servers 3.x-only, empty fields omitted).
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent b0e0b9e commit 0db56dc

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

app_wbtest.mbt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,39 @@ test "openapi operation deprecated flag" {
255255
}
256256
assert_eq(has_dep, false)
257257
}
258+
259+
///|
260+
test "openapi richer info: description/contact/license + servers" {
261+
let app = App::new()
262+
app.get("/ping", _ctx => text(200, "ok"))
263+
let doc = app.openapi(
264+
version=OpenApi31,
265+
title="Greet",
266+
description="qwq api",
267+
contact=Some({ name: "heke1228", url: "", email: "emm@qq.com" }),
268+
license=Some({ name: "Apache-2.0", url: "https://apache.org" }),
269+
servers=[{ url: "https://api.example.com", description: "prod" }],
270+
)
271+
guard doc is Object(m) else { fail("doc should be an object") }
272+
guard m.get("info") is Some(Object(info)) else {
273+
fail("info should be present")
274+
}
275+
assert_eq(info.get("description"), Some("qwq api".to_json()))
276+
guard info.get("contact") is Some(Object(c)) else {
277+
fail("contact should be present")
278+
}
279+
assert_eq(c.get("name"), Some("heke1228".to_json()))
280+
assert_eq(c.get("email"), Some("emm@qq.com".to_json()))
281+
// An empty contact field is omitted, not emitted as "".
282+
assert_eq(c.get("url"), None)
283+
guard info.get("license") is Some(Object(l)) else {
284+
fail("license should be present")
285+
}
286+
assert_eq(l.get("name"), Some("Apache-2.0".to_json()))
287+
guard m.get("servers") is Some(Array(sv)) else {
288+
fail("servers should be present")
289+
}
290+
assert_eq(sv.length(), 1)
291+
guard sv[0] is Object(s0) else { fail("server should be an object") }
292+
assert_eq(s0.get("url"), Some("https://api.example.com".to_json()))
293+
}

openapi.mbt

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,41 @@ fn path_parameters(path : String, version : OpenApiVersion) -> Array[Json] {
5454
///|
5555
/// Build the OpenAPI / Swagger document for the app as a `Json` value, walking
5656
/// the registered routes once into paths → methods → operations.
57+
/// OpenAPI `contact` info (← FastAPI's `contact`): every field optional, emitted
58+
/// only when non-empty.
59+
pub(all) struct Contact {
60+
name : String
61+
url : String
62+
email : String
63+
}
64+
65+
///|
66+
/// OpenAPI `license` info (← FastAPI's `license_info`): `name` required, `url`
67+
/// optional.
68+
pub(all) struct License {
69+
name : String
70+
url : String
71+
}
72+
73+
///|
74+
/// A `servers` entry (← FastAPI's `servers`): a base URL and an optional
75+
/// description.
76+
pub(all) struct Server {
77+
url : String
78+
description : String
79+
}
80+
81+
///|
5782
pub fn App::openapi(
5883
self : App,
5984
version? : OpenApiVersion = OpenApi31,
6085
title? : String = "moonapi",
6186
api_version? : String = "0.1.0",
87+
description? : String = "",
88+
terms_of_service? : String = "",
89+
contact? : Contact? = None,
90+
license? : License? = None,
91+
servers? : Array[Server] = [],
6292
) -> Json {
6393
let paths : Map[String, Json] = Map([])
6494
let defs : Map[String, Json] = Map([])
@@ -110,12 +140,64 @@ pub fn App::openapi(
110140
("title", title.to_json()),
111141
("version", api_version.to_json()),
112142
])
143+
if description != "" {
144+
info["description"] = description.to_json()
145+
}
146+
if terms_of_service != "" {
147+
info["termsOfService"] = terms_of_service.to_json()
148+
}
149+
match contact {
150+
Some(c) => {
151+
let cm : Map[String, Json] = Map([])
152+
if c.name != "" {
153+
cm["name"] = c.name.to_json()
154+
}
155+
if c.url != "" {
156+
cm["url"] = c.url.to_json()
157+
}
158+
if c.email != "" {
159+
cm["email"] = c.email.to_json()
160+
}
161+
if !cm.is_empty() {
162+
info["contact"] = cm.to_json()
163+
}
164+
}
165+
None => ()
166+
}
167+
match license {
168+
Some(l) => {
169+
let lm : Map[String, Json] = Map([("name", l.name.to_json())])
170+
if l.url != "" {
171+
lm["url"] = l.url.to_json()
172+
}
173+
info["license"] = lm.to_json()
174+
}
175+
None => ()
176+
}
113177
let (root_key, version_str) = version_field(version)
114178
let doc : Map[String, Json] = Map([
115179
(root_key, version_str.to_json()),
116180
("info", info.to_json()),
117181
("paths", paths.to_json()),
118182
])
183+
// `servers` is an OpenAPI 3.x construct (2.0 uses host/basePath), so emit it
184+
// only for the 3.x dialects.
185+
if !servers.is_empty() {
186+
match version {
187+
Swagger20 => ()
188+
_ => {
189+
let sarr : Array[Json] = []
190+
for s in servers {
191+
let sm : Map[String, Json] = Map([("url", s.url.to_json())])
192+
if s.description != "" {
193+
sm["description"] = s.description.to_json()
194+
}
195+
sarr.push(sm.to_json())
196+
}
197+
doc["servers"] = sarr.to_json()
198+
}
199+
}
200+
}
119201
// Hoisted named object schemas and the declared security schemes (the app's
120202
// own plus every mounted sub-app's): under `components` (`schemas` /
121203
// `securitySchemes`) in 3.x, or the top-level `definitions` /

0 commit comments

Comments
 (0)