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