Skip to main content

ploidy_core/ir/
spec.rs

1use indexmap::IndexMap;
2use itertools::Itertools;
3
4use crate::{
5    arena::Arena,
6    parse::{
7        self, Document, Info, Parameter, ParameterLocation, ParameterStyle as ParsedParameterStyle,
8        RefOrParameter, RefOrRequestBody, RefOrResponse, RefOrSchema, RequestBody, Response,
9    },
10};
11
12use super::{
13    error::IrError,
14    transform::transform,
15    types::{
16        InlineTypePath, InlineTypePathRoot, InlineTypePathSegment,
17        ParameterStyle as IrParameterStyle, SchemaTypeInfo, SpecInlineType, SpecOperation,
18        SpecParameter, SpecParameterInfo, SpecRequest, SpecResponse, SpecSchemaType, SpecType,
19        TypeInfo,
20    },
21};
22
23/// The intermediate representation of an OpenAPI document.
24///
25/// A [`Spec`] is a type tree lowered from a parsed document, with references
26/// still unresolved. Construct one with [`Spec::from_doc()`], then pass it to
27/// [`RawGraph::new()`] to build the type graph.
28///
29/// [`RawGraph::new()`]: crate::ir::RawGraph::new
30#[derive(Debug)]
31pub struct Spec<'a> {
32    /// The document's `info` section: title, OpenAPI version, etc.
33    pub info: &'a Info,
34    /// All operations extracted from the document's `paths` section.
35    pub operations: Vec<SpecOperation<'a>>,
36    /// Named schemas from `components/schemas`, keyed by name.
37    pub schemas: IndexMap<&'a str, SpecType<'a>>,
38}
39
40impl<'a> Spec<'a> {
41    /// Builds a [`Spec`] from a parsed OpenAPI [`Document`].
42    ///
43    /// Lowers each schema and operation to IR types, allocating all
44    /// long-lived data in the `arena`. Returns an error if the document is
45    /// malformed.
46    pub fn from_doc(arena: &'a Arena, doc: &'a Document) -> Result<Self, IrError> {
47        let schemas = match &doc.components {
48            Some(components) => components
49                .schemas
50                .iter()
51                .map(|(name, schema)| {
52                    let ty = transform(
53                        arena,
54                        doc,
55                        TypeInfo::Schema(SchemaTypeInfo {
56                            name,
57                            resource: schema.extension("x-resourceId"),
58                        }),
59                        schema,
60                    );
61                    (name.as_str(), ty)
62                })
63                .collect(),
64            None => IndexMap::new(),
65        };
66
67        let operations = doc
68            .paths
69            .iter()
70            .map(|(path, item)| {
71                let segments = parse::path::parse(arena, path.as_str())?;
72                Ok(item
73                    .operations()
74                    .map(move |(method, op)| (method, segments.clone(), op)))
75            })
76            .flatten_ok()
77            .map_ok(|(method, path, op)| -> Result<_, IrError> {
78                let resource = op.extension("x-resource-name");
79                let id = op.operation_id.as_deref().ok_or(IrError::NoOperationId)?;
80                let params = arena.alloc_slice(op.parameters.iter().filter_map(|param_or_ref| {
81                    let param = match param_or_ref {
82                        RefOrParameter::Other(p) => p,
83                        RefOrParameter::Ref(r) => {
84                            r.path.pointer().follow::<&Parameter>(doc).ok()?
85                        }
86                    };
87                    let ty: &_ = match &param.schema {
88                        Some(RefOrSchema::Ref(r)) => arena.alloc(SpecType::Ref(&r.path)),
89                        Some(RefOrSchema::Other(schema)) => arena.alloc(transform(
90                            arena,
91                            doc,
92                            InlineTypePath {
93                                root: InlineTypePathRoot::Resource(resource),
94                                segments: arena.alloc_slice_copy(&[
95                                    InlineTypePathSegment::Operation(id),
96                                    InlineTypePathSegment::Parameter(param.name.as_str()),
97                                ]),
98                            },
99                            schema,
100                        )),
101                        None => arena.alloc(
102                            SpecInlineType::Any(InlineTypePath {
103                                root: InlineTypePathRoot::Resource(resource),
104                                segments: arena.alloc_slice_copy(&[
105                                    InlineTypePathSegment::Operation(id),
106                                    InlineTypePathSegment::Parameter(param.name.as_str()),
107                                ]),
108                            })
109                            .into(),
110                        ),
111                    };
112                    let style = match (param.style, param.explode) {
113                        (Some(ParsedParameterStyle::DeepObject), Some(true) | None) => {
114                            Some(IrParameterStyle::DeepObject)
115                        }
116                        (Some(ParsedParameterStyle::SpaceDelimited), Some(false) | None) => {
117                            Some(IrParameterStyle::SpaceDelimited)
118                        }
119                        (Some(ParsedParameterStyle::PipeDelimited), Some(false) | None) => {
120                            Some(IrParameterStyle::PipeDelimited)
121                        }
122                        (None, None) => None,
123                        (Some(ParsedParameterStyle::Form) | None, Some(true) | None) => {
124                            Some(IrParameterStyle::Form { exploded: true })
125                        }
126                        (Some(ParsedParameterStyle::Form) | None, Some(false)) => {
127                            Some(IrParameterStyle::Form { exploded: false })
128                        }
129                        _ => None,
130                    };
131                    let info = SpecParameterInfo {
132                        name: param.name.as_str(),
133                        ty,
134                        required: param.required,
135                        description: param.description.as_deref(),
136                        style,
137                    };
138                    Some(match param.location {
139                        ParameterLocation::Path => SpecParameter::Path(info),
140                        ParameterLocation::Query => SpecParameter::Query(info),
141                        _ => return None,
142                    })
143                }));
144
145                let request = op
146                    .request_body
147                    .as_ref()
148                    .and_then(|request_or_ref| {
149                        let request = match request_or_ref {
150                            RefOrRequestBody::Other(rb) => rb,
151                            RefOrRequestBody::Ref(r) => {
152                                r.path.pointer().follow::<&RequestBody>(doc).ok()?
153                            }
154                        };
155
156                        Some(if request.content.contains_key("multipart/form-data") {
157                            RequestContent::Multipart
158                        } else if let Some(content) = request.content.get("application/json")
159                            && let Some(schema) = &content.schema
160                        {
161                            RequestContent::Json(schema)
162                        } else if let Some(content) = request.content.get("*/*")
163                            && let Some(schema) = &content.schema
164                        {
165                            RequestContent::Json(schema)
166                        } else {
167                            RequestContent::Any
168                        })
169                    })
170                    .map(|content| match content {
171                        RequestContent::Multipart => SpecRequest::Multipart,
172                        RequestContent::Json(RefOrSchema::Ref(r)) => {
173                            SpecRequest::Json(arena.alloc(SpecType::Ref(&r.path)))
174                        }
175                        RequestContent::Json(RefOrSchema::Other(schema)) => {
176                            SpecRequest::Json(arena.alloc(transform(
177                                arena,
178                                doc,
179                                InlineTypePath {
180                                    root: InlineTypePathRoot::Resource(resource),
181                                    segments: arena.alloc_slice_copy(&[
182                                        InlineTypePathSegment::Operation(id),
183                                        InlineTypePathSegment::Request,
184                                    ]),
185                                },
186                                schema,
187                            )))
188                        }
189                        RequestContent::Any => SpecRequest::Json(
190                            arena.alloc(
191                                SpecInlineType::Any(InlineTypePath {
192                                    root: InlineTypePathRoot::Resource(resource),
193                                    segments: arena.alloc_slice_copy(&[
194                                        InlineTypePathSegment::Operation(id),
195                                        InlineTypePathSegment::Request,
196                                    ]),
197                                })
198                                .into(),
199                            ),
200                        ),
201                    });
202
203                let response = {
204                    let mut statuses = op
205                        .responses
206                        .keys()
207                        .filter_map(|status| Some((status.as_str(), status.parse::<u16>().ok()?)))
208                        .collect_vec();
209                    statuses.sort_unstable_by_key(|&(_, code)| code);
210                    let key = statuses
211                        .iter()
212                        .find(|&(_, code)| matches!(code, 200..300))
213                        .map(|&(key, _)| key)
214                        .unwrap_or("default");
215
216                    op.responses
217                        .get(key)
218                        .and_then(|response_or_ref| {
219                            let response = match response_or_ref {
220                                RefOrResponse::Other(r) => r,
221                                RefOrResponse::Ref(r) => {
222                                    r.path.pointer().follow::<&Response>(doc).ok()?
223                                }
224                            };
225                            response.content.as_ref()
226                        })
227                        .map(|content| {
228                            if let Some(content) = content.get("application/json")
229                                && let Some(schema) = &content.schema
230                            {
231                                ResponseContent::Json(schema)
232                            } else if let Some(content) = content.get("*/*")
233                                && let Some(schema) = &content.schema
234                            {
235                                ResponseContent::Json(schema)
236                            } else {
237                                ResponseContent::Any
238                            }
239                        })
240                        .map(|content| match content {
241                            ResponseContent::Json(RefOrSchema::Ref(r)) => {
242                                SpecResponse::Json(arena.alloc(SpecType::Ref(&r.path)))
243                            }
244                            ResponseContent::Json(RefOrSchema::Other(schema)) => {
245                                SpecResponse::Json(arena.alloc(transform(
246                                    arena,
247                                    doc,
248                                    InlineTypePath {
249                                        root: InlineTypePathRoot::Resource(resource),
250                                        segments: arena.alloc_slice_copy(&[
251                                            InlineTypePathSegment::Operation(id),
252                                            InlineTypePathSegment::Response,
253                                        ]),
254                                    },
255                                    schema,
256                                )))
257                            }
258                            ResponseContent::Any => SpecResponse::Json(
259                                arena.alloc(
260                                    SpecInlineType::Any(InlineTypePath {
261                                        root: InlineTypePathRoot::Resource(resource),
262                                        segments: arena.alloc_slice_copy(&[
263                                            InlineTypePathSegment::Operation(id),
264                                            InlineTypePathSegment::Response,
265                                        ]),
266                                    })
267                                    .into(),
268                                ),
269                            ),
270                        })
271                };
272
273                Ok(SpecOperation {
274                    resource,
275                    id,
276                    method,
277                    path: arena.alloc_slice_copy(&path),
278                    description: op.description.as_deref(),
279                    params,
280                    request,
281                    response,
282                })
283            })
284            .flatten_ok()
285            .collect::<Result<_, IrError>>()?;
286
287        Ok(Spec {
288            info: &doc.info,
289            operations,
290            schemas,
291        })
292    }
293
294    /// Resolves a [`SpecType`], following type references through the spec.
295    #[inline]
296    pub(super) fn resolve(&'a self, mut ty: &'a SpecType<'a>) -> ResolvedSpecType<'a> {
297        loop {
298            match ty {
299                SpecType::Schema(ty) => return ResolvedSpecType::Schema(ty),
300                SpecType::Inline(ty) => return ResolvedSpecType::Inline(ty),
301                SpecType::Ref(r) => ty = &self.schemas[&*r.name()],
302            }
303        }
304    }
305}
306
307/// A dereferenced type in the spec.
308///
309/// The derived [`Eq`] and [`Hash`][std::hash::Hash] implementations
310/// use structural equality, not pointer identity. Multiple [`SpecType`]s
311/// in a [`Spec`] may resolve to the same logical type, so value-based
312/// comparison is necessary.
313#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
314pub(super) enum ResolvedSpecType<'a> {
315    Schema(&'a SpecSchemaType<'a>),
316    Inline(&'a SpecInlineType<'a>),
317}
318
319#[derive(Clone, Copy, Debug)]
320enum RequestContent<'a> {
321    Multipart,
322    Json(&'a RefOrSchema),
323    Any,
324}
325
326#[derive(Clone, Copy, Debug)]
327enum ResponseContent<'a> {
328    Json(&'a RefOrSchema),
329    Any,
330}