1use sofie_model::App;
2
3use super::json::Json;
4use super::{
5 Answer, Api, Endpoint, Property, Scalar, Schema, Shape, VALUE_SHAPES, ValueShape,
6 value_properties,
7};
8
9const SCHEMAS: &str = "#/components/schemas/";
10const COMMITTED: &str = "Committed";
11
12#[must_use]
13pub fn openapi(app: &App) -> String {
14 let api = Api::of(app);
15 let mut tags: Vec<&str> = api
16 .endpoints
17 .iter()
18 .map(|endpoint| endpoint.area.as_str())
19 .collect();
20 tags.sort_unstable();
21 tags.dedup();
22 Json::object([
23 ("openapi", Json::text("3.1.0")),
24 (
25 "info",
26 Json::object([
27 ("title", Json::text(&api.title)),
28 ("version", Json::text("unversioned")),
29 ]),
30 ),
31 (
32 "tags",
33 Json::Array(
34 tags.iter()
35 .map(|tag| Json::object([("name", Json::text(tag))]))
36 .collect(),
37 ),
38 ),
39 ("paths", paths(&api.endpoints)),
40 ("components", Json::object([("schemas", schemas(&api))])),
41 ])
42 .render()
43}
44
45fn paths(endpoints: &[Endpoint]) -> Json {
46 let mut paths: Vec<(String, Json)> = Vec::new();
47 for endpoint in endpoints {
48 let operation = (
49 endpoint.method.name().to_ascii_lowercase(),
50 operation(endpoint),
51 );
52 match paths.iter_mut().find(|(path, _)| *path == endpoint.path) {
53 Some((_, Json::Object(operations))) => operations.push(operation),
54 _ => paths.push((endpoint.path.clone(), Json::Object(vec![operation]))),
55 }
56 }
57 Json::Object(paths)
58}
59
60fn operation(endpoint: &Endpoint) -> Json {
61 let mut fields = vec![
62 ("operationId".to_owned(), Json::text(&endpoint.name)),
63 (
64 "tags".to_owned(),
65 Json::Array(vec![Json::text(&endpoint.area)]),
66 ),
67 ];
68 if let Some(summary) = &endpoint.summary {
69 fields.push(("summary".to_owned(), Json::text(summary)));
70 }
71 let parameters = parameters(endpoint);
72 if !parameters.is_empty() {
73 fields.push(("parameters".to_owned(), Json::Array(parameters)));
74 }
75 if let Some(input) = &endpoint.input {
76 fields.push(("requestBody".to_owned(), request_body(input)));
77 }
78 fields.push(("responses".to_owned(), responses(endpoint)));
79 Json::Object(fields)
80}
81
82fn parameters(endpoint: &Endpoint) -> Vec<Json> {
83 let path = endpoint.parameters.iter().map(|name| {
84 Json::object([
85 ("name", Json::text(name)),
86 ("in", Json::text("path")),
87 ("required", Json::Boolean(true)),
88 ("schema", Json::object([("type", Json::text("string"))])),
89 ])
90 });
91 let query = endpoint
92 .query
93 .iter()
94 .flat_map(|query| &query.properties)
95 .map(|property| {
96 let shape = match &property.shape {
97 Shape::Optional(inner) => inner,
98 shape => shape,
99 };
100 Json::object([
101 ("name", Json::text(&property.name)),
102 ("in", Json::text("query")),
103 ("required", Json::Boolean(property.required)),
104 ("schema", schema_of(shape)),
105 ])
106 });
107 path.chain(query).collect()
108}
109
110fn request_body(input: &str) -> Json {
111 Json::object([
112 ("required", Json::Boolean(true)),
113 (
114 "content",
115 Json::object([(
116 "application/json",
117 Json::object([("schema", reference(input))]),
118 )]),
119 ),
120 ])
121}
122
123fn responses(endpoint: &Endpoint) -> Json {
124 let mut responses = vec![match &endpoint.output {
125 Answer::Empty => (
126 "204".to_owned(),
127 Json::object([("description", Json::text("Done"))]),
128 ),
129 Answer::Data(shape) => (
130 "200".to_owned(),
131 Json::object([
132 ("description", Json::text("The handler's data")),
133 (
134 "content",
135 Json::object([(
136 "application/json",
137 Json::object([("schema", schema_of(shape))]),
138 )]),
139 ),
140 ]),
141 ),
142 }];
143 if endpoint.input.is_some() || endpoint.query.is_some() {
144 responses.push((
145 "422".to_owned(),
146 Json::object([("description", Json::text("A field is invalid"))]),
147 ));
148 }
149 Json::Object(responses)
150}
151
152fn schemas(api: &Api) -> Json {
153 let mut schemas = vec![(
154 COMMITTED.to_owned(),
155 object_schema(&[
156 ("id", Scalar::Uuid, true),
157 ("version", Scalar::Integer, true),
158 ]),
159 )];
160 schemas.extend(
161 VALUE_SHAPES
162 .iter()
163 .filter(|(name, _)| api.mentions_value(name))
164 .map(|(name, shape)| ((*name).to_owned(), object_schema(shape))),
165 );
166 schemas.extend(api.schemas.iter().map(|(name, schema)| {
167 let json = match schema {
168 Schema::Enum(names) => Json::object([
169 ("type", Json::text("string")),
170 (
171 "enum",
172 Json::Array(names.iter().map(|name| Json::text(name)).collect()),
173 ),
174 ]),
175 Schema::Object(properties) => properties_schema(properties),
176 };
177 (name.clone(), json)
178 }));
179 Json::Object(schemas)
180}
181
182fn object_schema(shape: ValueShape) -> Json {
183 properties_schema(&value_properties(shape))
184}
185
186fn properties_schema(properties: &[Property]) -> Json {
187 let required: Vec<Json> = properties
188 .iter()
189 .filter(|property| property.required)
190 .map(|property| Json::text(&property.name))
191 .collect();
192 Json::object([
193 ("type", Json::text("object")),
194 (
195 "properties",
196 Json::Object(
197 properties
198 .iter()
199 .map(|property| (property.name.clone(), schema_of(&property.shape)))
200 .collect(),
201 ),
202 ),
203 ("required", Json::Array(required)),
204 ])
205}
206
207fn schema_of(shape: &Shape) -> Json {
208 match shape {
209 Shape::Scalar(scalar) => scalar_schema(*scalar),
210 Shape::Value(name) => reference(name),
211 Shape::Named(name) => reference(name),
212 Shape::Committed => reference(COMMITTED),
213 Shape::List(item) => {
214 Json::object([("type", Json::text("array")), ("items", schema_of(item))])
215 }
216 Shape::Optional(inner) => any_of(inner, "null"),
217 Shape::Tested(inner) => any_of(inner, "boolean"),
218 Shape::Unknown(written) => Json::object([(
219 "description",
220 Json::Text(format!(
221 "`{written}`, which `sofie openapi` cannot describe without compiling; \
222 answer a read model"
223 )),
224 )]),
225 }
226}
227
228fn any_of(inner: &Shape, kind: &str) -> Json {
230 Json::object([(
231 "anyOf",
232 Json::Array(vec![
233 schema_of(inner),
234 Json::object([("type", Json::text(kind))]),
235 ]),
236 )])
237}
238
239fn scalar_schema(scalar: Scalar) -> Json {
240 let kind = match scalar {
241 Scalar::Integer => "integer",
242 Scalar::Number => "number",
243 Scalar::Boolean => "boolean",
244 _ => "string",
245 };
246 let mut fields = vec![("type".to_owned(), Json::text(kind))];
247 if let Some(format) = scalar.format() {
248 fields.push(("format".to_owned(), Json::text(format)));
249 }
250 Json::Object(fields)
251}
252
253fn reference(name: &str) -> Json {
254 Json::object([("$ref", Json::Text(format!("{SCHEMAS}{name}")))])
255}
256
257#[cfg(test)]
258mod tests {
259 use super::{openapi, schema_of};
260 use crate::api::tests::shop;
261 use crate::api::{Scalar, Shape};
262
263 #[test]
264 fn describes_a_screen_and_an_action_with_their_schemas() {
265 let document = openapi(&shop());
266
267 let expected = include_str!("../../tests/golden/openapi.json");
268 assert_eq!(document, expected, "{document}");
269 }
270
271 #[test]
272 fn a_tested_boolean_matches_either_branch() {
273 let tested = Shape::Tested(Box::new(Shape::Scalar(Scalar::Boolean)));
274
275 let schema = schema_of(&tested).render();
276
277 assert!(schema.starts_with("{\n \"anyOf\""), "{schema}");
278 }
279
280 #[test]
281 fn a_null_optional_of_an_undescribed_type_matches_either_branch() {
282 let optional = Shape::Optional(Box::new(Shape::Unknown("Opaque".to_owned())));
283
284 let schema = schema_of(&optional).render();
285
286 assert!(schema.starts_with("{\n \"anyOf\""), "{schema}");
287 }
288}