Skip to main content

ploidy_core/ir/
spec.rs

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