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},
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| segment.fragments())
128                            .filter_map(|fragment| match fragment {
129                                &PathFragment::Param(name) => Some(name),
130                                _ => None,
131                            })
132                            .filter(|&name| seen.insert(name))
133                            .map(|name| {
134                                match declared.shift_remove(&(name, ParameterLocation::Path)) {
135                                    Some(param) => Source::Declared(param),
136                                    None => Source::Synthesized(name),
137                                }
138                            })
139                            .collect_vec()
140                    };
141
142                    // Append remaining parameters in declaration order.
143                    sources.extend(declared.into_iter().filter_map(|((_, location), param)| {
144                        match location {
145                            // Drop declared path parameters that are
146                            // absent from the template.
147                            ParameterLocation::Path => None,
148                            _ => Some(Source::Declared(param)),
149                        }
150                    }));
151
152                    // Lower all sources to spec parameters.
153                    let params = sources.into_iter().filter_map(|source| match source {
154                        Source::Declared(param) => {
155                            let ty: &_ = match &param.schema {
156                                Some(RefOrSchema::Ref(r)) => arena.alloc(SpecType::Ref(r)),
157                                Some(RefOrSchema::Inline(schema)) => arena
158                                    .alloc(transform_with_context(&context, ids.next(), schema)),
159                                None => arena.alloc(SpecInlineType::Any(ids.next()).into()),
160                            };
161                            let style = match (param.style, param.explode) {
162                                (Some(ParsedParameterStyle::DeepObject), Some(true) | None) => {
163                                    Some(IrParameterStyle::DeepObject)
164                                }
165                                (
166                                    Some(ParsedParameterStyle::SpaceDelimited),
167                                    Some(false) | None,
168                                ) => Some(IrParameterStyle::SpaceDelimited),
169                                (Some(ParsedParameterStyle::PipeDelimited), Some(false) | None) => {
170                                    Some(IrParameterStyle::PipeDelimited)
171                                }
172                                (None, None) => None,
173                                (Some(ParsedParameterStyle::Form) | None, Some(true) | None) => {
174                                    Some(IrParameterStyle::Form { exploded: true })
175                                }
176                                (Some(ParsedParameterStyle::Form) | None, Some(false)) => {
177                                    Some(IrParameterStyle::Form { exploded: false })
178                                }
179                                _ => None,
180                            };
181                            let info = SpecParameterInfo {
182                                name: param.name.as_str(),
183                                ty,
184                                required: param.required,
185                                description: param.description.as_deref(),
186                                style,
187                            };
188                            Some(match param.location {
189                                ParameterLocation::Path => SpecParameter::Path(info),
190                                ParameterLocation::Query => SpecParameter::Query(info),
191                                _ => return None,
192                            })
193                        }
194                        Source::Synthesized(name) => {
195                            let ty: &_ = arena.alloc(SpecInlineType::Any(ids.next()).into());
196                            Some(SpecParameter::Path(SpecParameterInfo {
197                                name,
198                                ty,
199                                required: true,
200                                description: None,
201                                style: None,
202                            }))
203                        }
204                    });
205
206                    arena.alloc_slice(params)
207                };
208
209                let request = item
210                    .op
211                    .request_body
212                    .as_ref()
213                    .and_then(|request_or_ref| {
214                        let request = match request_or_ref {
215                            RefOrRequestBody::Other(rb) => rb,
216                            RefOrRequestBody::Ref(r) => {
217                                r.ref_.pointer().follow::<&RequestBody>(doc).ok()?
218                            }
219                        };
220
221                        Some(if request.content.contains_key("multipart/form-data") {
222                            RequestContent::Multipart
223                        } else if let Some(content) = request.content.get("application/json")
224                            && let Some(schema) = &content.schema
225                        {
226                            RequestContent::Json(schema)
227                        } else if let Some(content) = request.content.get("*/*")
228                            && let Some(schema) = &content.schema
229                        {
230                            RequestContent::Json(schema)
231                        } else {
232                            RequestContent::Any
233                        })
234                    })
235                    .map(|content| match content {
236                        RequestContent::Multipart => SpecRequest::Multipart,
237                        RequestContent::Json(RefOrSchema::Ref(r)) => {
238                            SpecRequest::Json(arena.alloc(SpecType::Ref(r)))
239                        }
240                        RequestContent::Json(RefOrSchema::Inline(schema)) => SpecRequest::Json(
241                            arena.alloc(transform_with_context(&context, ids.next(), schema)),
242                        ),
243                        RequestContent::Any => {
244                            SpecRequest::Json(arena.alloc(SpecInlineType::Any(ids.next()).into()))
245                        }
246                    });
247
248                let response = {
249                    let mut statuses = item
250                        .op
251                        .responses
252                        .keys()
253                        .filter_map(|status| Some((status.as_str(), status.parse::<u16>().ok()?)))
254                        .collect_vec();
255                    statuses.sort_unstable_by_key(|&(_, code)| code);
256                    let key = statuses
257                        .iter()
258                        .find(|&(_, code)| matches!(code, 200..300))
259                        .map(|&(key, _)| key)
260                        .unwrap_or("default");
261
262                    item.op
263                        .responses
264                        .get(key)
265                        .and_then(|response_or_ref| {
266                            let response = match response_or_ref {
267                                RefOrResponse::Other(r) => r,
268                                RefOrResponse::Ref(r) => {
269                                    r.ref_.pointer().follow::<&Response>(doc).ok()?
270                                }
271                            };
272                            response.content.as_ref()
273                        })
274                        .map(|content| {
275                            if let Some(content) = content.get("application/json")
276                                && let Some(schema) = &content.schema
277                            {
278                                ResponseContent::Json(schema)
279                            } else if let Some(content) = content.get("*/*")
280                                && let Some(schema) = &content.schema
281                            {
282                                ResponseContent::Json(schema)
283                            } else {
284                                ResponseContent::Any
285                            }
286                        })
287                        .map(|content| match content {
288                            ResponseContent::Json(RefOrSchema::Ref(r)) => {
289                                SpecResponse::Json(arena.alloc(SpecType::Ref(r)))
290                            }
291                            ResponseContent::Json(RefOrSchema::Inline(schema)) => {
292                                SpecResponse::Json(arena.alloc(transform_with_context(
293                                    &context,
294                                    ids.next(),
295                                    schema,
296                                )))
297                            }
298                            ResponseContent::Any => SpecResponse::Json(
299                                arena.alloc(SpecInlineType::Any(ids.next()).into()),
300                            ),
301                        })
302                };
303
304                Ok(SpecOperation {
305                    resource,
306                    id: OperationId::new(id),
307                    method: item.method,
308                    path: item.path,
309                    description: item.op.description.as_deref(),
310                    params,
311                    request,
312                    response,
313                })
314            })
315            .flatten_ok()
316            .collect::<Result<_, IrError>>()?;
317
318        Ok(Spec {
319            info: &doc.info,
320            operations,
321            schemas,
322            ids,
323        })
324    }
325
326    /// Resolves a [`SpecType`], following type references through the spec.
327    #[inline]
328    pub(super) fn resolve(&'a self, mut ty: &'a SpecType<'a>) -> ResolvedSpecType<'a> {
329        loop {
330            match ty {
331                SpecType::Schema(ty) => return ResolvedSpecType::Schema(ty),
332                SpecType::Inline(ty) => return ResolvedSpecType::Inline(ty),
333                SpecType::Ref(r) => ty = &self.schemas[&*r.name()],
334            }
335        }
336    }
337}
338
339/// A dereferenced type in the spec.
340///
341/// The derived [`Eq`] and [`Hash`][std::hash::Hash] implementations
342/// use structural equality, not pointer identity. Multiple [`SpecType`]s
343/// in a [`Spec`] may resolve to the same logical type, so value-based
344/// comparison is necessary.
345#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
346pub(super) enum ResolvedSpecType<'a> {
347    Schema(&'a SpecSchemaType<'a>),
348    Inline(&'a SpecInlineType<'a>),
349}
350
351#[derive(Clone, Copy, Debug)]
352enum RequestContent<'a> {
353    Multipart,
354    Json(&'a RefOrSchema),
355    Any,
356}
357
358#[derive(Clone, Copy, Debug)]
359enum ResponseContent<'a> {
360    Json(&'a RefOrSchema),
361    Any,
362}
363
364#[derive(Clone, Copy, Debug)]
365struct PathOperation<'a> {
366    path: ParsedPath<'a>,
367    method: Method,
368    params: &'a [RefOrParameter],
369    op: &'a Operation,
370}