Skip to main content

sofie_cli/api/
openapi.rs

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
228/// Not `oneOf`: a value may match both branches, as a boolean does a tested boolean.
229fn 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}