Skip to main content

ploidy_core/ir/
spec.rs

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