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 ¶m.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}