Skip to main content

poolster_core/
ast.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// A normalized, language-neutral description of an API.
7///
8/// Adapters own the conversion from OpenAPI (or another input) into this
9/// structure. Generator plugins never need to know which source format was
10/// used. `SchemaValue` intentionally retains OpenAPI's compositional type
11/// system instead of reducing it to target-language strings.
12#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Api {
14    pub name: String,
15    pub version: String,
16    #[serde(default)]
17    pub schemas: Vec<Schema>,
18    #[serde(default)]
19    pub operations: Vec<Operation>,
20    /// Adapter-specific source metadata that is safe for generic transforms to
21    /// inspect without coupling the generator to OpenAPI.
22    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
23    pub annotations: BTreeMap<String, Value>,
24}
25
26/// A named reusable schema, normally an OpenAPI component schema.
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Schema {
29    pub name: String,
30    pub value: SchemaValue,
31}
32
33impl Schema {
34    pub fn new(name: impl Into<String>, value: SchemaValue) -> Self {
35        Self {
36            name: name.into(),
37            value,
38        }
39    }
40}
41
42/// A recursively composable API type. This is deliberately target neutral:
43/// plugins decide whether (for example) `String { format: uuid }` becomes a
44/// branded TypeScript string, a Python UUID, or a Rust `uuid::Uuid`.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct SchemaValue {
47    pub kind: SchemaKind,
48    #[serde(default)]
49    pub nullable: bool,
50    /// A target-neutral schema wrapper for contexts which retain optionality
51    /// on the value rather than on an enclosing object property.
52    #[serde(default)]
53    pub optional: bool,
54    /// A target-neutral schema wrapper for values which accept either `null`
55    /// or `undefined`. OpenAPI itself normally models this through a nullable,
56    /// non-required property, but retaining it makes lossless adapters and
57    /// schema generators possible.
58    #[serde(default)]
59    pub nullish: bool,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub format: Option<String>,
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub enum_values: Vec<Value>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub const_value: Option<Value>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub default: Option<Value>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub title: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub description: Option<String>,
72    #[serde(default)]
73    pub deprecated: bool,
74    #[serde(default)]
75    pub read_only: bool,
76    #[serde(default)]
77    pub write_only: bool,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub discriminator: Option<Discriminator>,
80    /// Validation keywords and vendor-neutral OpenAPI keywords which do not
81    /// alter the structural shape. Keeping them here means an adapter does not
82    /// throw away constraints that a target plugin may support later.
83    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84    pub constraints: BTreeMap<String, Value>,
85    /// Unknown `x-*` and future schema keywords. This is a forward-compatible
86    /// lossless escape hatch; core generators must not depend on it.
87    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
88    pub extensions: BTreeMap<String, Value>,
89}
90
91impl SchemaValue {
92    pub fn new(kind: SchemaKind) -> Self {
93        Self {
94            kind,
95            nullable: false,
96            optional: false,
97            nullish: false,
98            format: None,
99            enum_values: Vec::new(),
100            const_value: None,
101            default: None,
102            title: None,
103            description: None,
104            deprecated: false,
105            read_only: false,
106            write_only: false,
107            discriminator: None,
108            constraints: BTreeMap::new(),
109            extensions: BTreeMap::new(),
110        }
111    }
112
113    pub fn unknown() -> Self {
114        Self::new(SchemaKind::Any)
115    }
116
117    pub fn reference(reference: impl Into<String>) -> Self {
118        Self::new(SchemaKind::Reference {
119            reference: reference.into(),
120        })
121    }
122}
123
124/// The structural part of a [`SchemaValue`].
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(tag = "kind", rename_all = "snake_case")]
127pub enum SchemaKind {
128    Any,
129    Null,
130    Boolean,
131    Integer,
132    Number,
133    String,
134    Array {
135        items: Box<SchemaValue>,
136    },
137    Object {
138        fields: Vec<Field>,
139        additional_properties: AdditionalProperties,
140    },
141    /// The full OpenAPI/JSON Schema reference is retained to support local,
142    /// external, and non-component references. Use `reference_name` when a
143    /// target needs the conventional final JSON-pointer segment.
144    Reference {
145        reference: String,
146    },
147    OneOf {
148        variants: Vec<SchemaValue>,
149    },
150    AnyOf {
151        variants: Vec<SchemaValue>,
152    },
153    AllOf {
154        variants: Vec<SchemaValue>,
155    },
156    Not {
157        schema: Box<SchemaValue>,
158    },
159}
160
161impl SchemaKind {
162    pub fn reference_name(&self) -> Option<&str> {
163        let Self::Reference { reference } = self else {
164            return None;
165        };
166        Some(reference.rsplit('/').next().unwrap_or(reference))
167    }
168}
169
170/// OpenAPI's `additionalProperties` has three semantically distinct states.
171#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum AdditionalProperties {
174    /// The keyword was omitted. A generator can apply the relevant OpenAPI
175    /// version's default without confusing it with an explicit `true`.
176    #[default]
177    Unspecified,
178    Any,
179    Forbidden,
180    Schema {
181        value: Box<SchemaValue>,
182    },
183}
184
185#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
186pub struct Discriminator {
187    pub property_name: String,
188    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189    pub mapping: BTreeMap<String, String>,
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193pub struct Field {
194    pub name: String,
195    pub value: SchemaValue,
196    #[serde(default)]
197    pub required: bool,
198    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
199    pub annotations: BTreeMap<String, Value>,
200}
201
202#[derive(Clone, Debug, PartialEq, Eq)]
203pub enum HttpMethod {
204    Get,
205    Post,
206    Put,
207    Patch,
208    Delete,
209    Head,
210    Options,
211    Trace,
212    Query,
213    Custom(String),
214}
215
216impl HttpMethod {
217    pub fn as_str(&self) -> &str {
218        match self {
219            Self::Get => "GET",
220            Self::Post => "POST",
221            Self::Put => "PUT",
222            Self::Patch => "PATCH",
223            Self::Delete => "DELETE",
224            Self::Head => "HEAD",
225            Self::Options => "OPTIONS",
226            Self::Trace => "TRACE",
227            Self::Query => "QUERY",
228            Self::Custom(method) => method,
229        }
230    }
231}
232
233impl HttpMethod {
234    /// HTTP methods are case-sensitive ASCII tokens. Unknown methods default to
235    /// unsafe retry semantics; callers must explicitly supply idempotency policy.
236    pub fn parse(method: &str) -> Result<Self, String> {
237        if method.is_empty()
238            || method.len() > 256
239            || !method
240                .bytes()
241                .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
242        {
243            return Err("HTTP method must be an ASCII token of 1..=256 bytes".into());
244        }
245        if method != method.to_ascii_uppercase()
246            && matches!(
247                method.to_ascii_uppercase().as_str(),
248                "GET"
249                    | "POST"
250                    | "PUT"
251                    | "PATCH"
252                    | "DELETE"
253                    | "HEAD"
254                    | "OPTIONS"
255                    | "TRACE"
256                    | "QUERY"
257            )
258        {
259            return Err("standard HTTP methods must use canonical uppercase tokens".into());
260        }
261        Ok(match method {
262            "GET" => Self::Get,
263            "POST" => Self::Post,
264            "PUT" => Self::Put,
265            "PATCH" => Self::Patch,
266            "DELETE" => Self::Delete,
267            "HEAD" => Self::Head,
268            "OPTIONS" => Self::Options,
269            "TRACE" => Self::Trace,
270            "QUERY" => Self::Query,
271            other => Self::Custom(other.to_owned()),
272        })
273    }
274}
275impl Serialize for HttpMethod {
276    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
277        serializer.serialize_str(self.as_str())
278    }
279}
280impl<'de> Deserialize<'de> for HttpMethod {
281    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
282        let method = String::deserialize(deserializer)?;
283        Self::parse(&method).map_err(serde::de::Error::custom)
284    }
285}
286
287#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
288pub struct Operation {
289    pub id: String,
290    pub method: HttpMethod,
291    pub path: String,
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub parameters: Vec<OperationParameter>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub request_body: Option<OperationRequestBody>,
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub responses: Vec<OperationResponse>,
298    /// OpenAPI security requirements, preserving its OR-of-AND structure.
299    /// Each entry is an alternative; every named scheme inside an entry is
300    /// required together. Values are OAuth/OpenID scopes where applicable.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub security: Vec<SecurityRequirement>,
303    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
304    pub annotations: BTreeMap<String, Value>,
305}
306
307impl Default for Operation {
308    fn default() -> Self {
309        Self {
310            id: String::new(),
311            method: HttpMethod::Get,
312            path: String::new(),
313            parameters: Vec::new(),
314            request_body: None,
315            responses: Vec::new(),
316            security: Vec::new(),
317            annotations: BTreeMap::new(),
318        }
319    }
320}
321
322/// A path, query, header, or cookie input accepted by an operation. `location`
323/// intentionally remains a string so the neutral AST can preserve future or
324/// vendor-defined OpenAPI locations instead of rejecting an otherwise valid
325/// source document.
326#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
327pub struct OperationParameter {
328    pub name: String,
329    pub location: String,
330    #[serde(default)]
331    pub required: bool,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub schema: Option<SchemaValue>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub description: Option<String>,
336    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
337    pub annotations: BTreeMap<String, Value>,
338}
339
340impl Operation {
341    /// The preferred request schema, favoring JSON when multiple media types exist.
342    pub fn request_schema(&self) -> Option<&SchemaValue> {
343        preferred_schema(&self.request_body.as_ref()?.media_types)
344    }
345
346    /// The first declared successful response's schema, favoring JSON media.
347    /// Error and default responses are not treated as successful responses.
348    pub fn success_schema(&self) -> Option<&SchemaValue> {
349        preferred_schema(
350            &self
351                .responses
352                .iter()
353                .find(|response| response.status.starts_with('2'))?
354                .media_types,
355        )
356    }
357}
358
359fn preferred_schema(media_types: &[OperationMediaType]) -> Option<&SchemaValue> {
360    media_types
361        .iter()
362        .find(|media| {
363            media.content_type == "application/json" || media.content_type.ends_with("+json")
364        })
365        .or_else(|| media_types.first())?
366        .schema
367        .as_ref()
368}
369
370/// An operation request body, retaining the schemas for each media type.
371#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
372pub struct OperationRequestBody {
373    #[serde(default)]
374    pub required: bool,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub description: Option<String>,
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub media_types: Vec<OperationMediaType>,
379}
380
381/// A response grouped by OpenAPI status code (or `default`).
382#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
383pub struct OperationResponse {
384    pub status: String,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub description: Option<String>,
387    #[serde(default, skip_serializing_if = "Vec::is_empty")]
388    pub media_types: Vec<OperationMediaType>,
389}
390
391impl OperationRequestBody {
392    pub fn json(schema: SchemaValue, required: bool) -> Self {
393        Self {
394            required,
395            description: None,
396            media_types: vec![OperationMediaType {
397                content_type: "application/json".into(),
398                schema: Some(schema),
399            }],
400        }
401    }
402}
403
404impl OperationResponse {
405    pub fn json(status: impl Into<String>, schema: SchemaValue) -> Self {
406        Self {
407            status: status.into(),
408            description: None,
409            media_types: vec![OperationMediaType {
410                content_type: "application/json".into(),
411                schema: Some(schema),
412            }],
413        }
414    }
415}
416
417/// A named representation carried by a request or response.
418#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
419pub struct OperationMediaType {
420    pub content_type: String,
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub schema: Option<SchemaValue>,
423}
424
425/// One OpenAPI Security Requirement Object. Multiple values in
426/// `Operation::security` are alternatives; the map entries in one value are
427/// conjunctive scheme requirements.
428#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
429pub struct SecurityRequirement {
430    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
431    pub schemes: BTreeMap<String, Vec<String>>,
432}
433
434/// Reusable security-scheme metadata from OpenAPI components.
435///
436/// An operation's [`SecurityRequirement`] says which named schemes it needs;
437/// this catalog says how an SDK supplies each credential. Keeping it separate
438/// lets plugins opt into authentication support without parsing sidecar JSON.
439#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
440pub struct SecuritySchemeCatalog {
441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
442    pub schemes: Vec<SecurityScheme>,
443}
444
445/// A named OpenAPI component security scheme.
446#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
447pub struct SecurityScheme {
448    pub name: String,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub description: Option<String>,
451    pub kind: SecuritySchemeKind,
452}
453
454/// The credential metadata a generator needs to materialize an OpenAPI
455/// security scheme. Unsupported/future scheme kinds are retained as `Other`
456/// rather than discarded at the sidecar boundary.
457#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
458pub enum SecuritySchemeKind {
459    ApiKey {
460        name: Option<String>,
461        location: Option<String>,
462    },
463    Http {
464        scheme: Option<String>,
465        bearer_format: Option<String>,
466    },
467    OAuth2 {
468        flows: Vec<OAuthFlow>,
469        metadata_url: Option<String>,
470    },
471    OpenIdConnect {
472        discovery_url: Option<String>,
473    },
474    Other {
475        type_name: String,
476    },
477}
478
479/// One named OAuth2 flow, including its token endpoints and advertised scopes.
480#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
481pub struct OAuthFlow {
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub device_authorization_url: Option<String>,
484    pub flow_type: String,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub authorization_url: Option<String>,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub token_url: Option<String>,
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub refresh_url: Option<String>,
491    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
492    pub scopes: BTreeMap<String, String>,
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn operation_schema_selection_uses_declared_json_media() {
501        let mut operation = Operation {
502            request_body: Some(OperationRequestBody::json(
503                SchemaValue::reference("Input"),
504                true,
505            )),
506            responses: vec![OperationResponse::json(
507                "200",
508                SchemaValue::reference("Output"),
509            )],
510            ..Operation::default()
511        };
512        operation.request_body.as_mut().unwrap().media_types.insert(
513            0,
514            OperationMediaType {
515                content_type: "text/plain".into(),
516                schema: Some(SchemaValue::new(SchemaKind::String)),
517            },
518        );
519        operation.responses[0].media_types.insert(
520            0,
521            OperationMediaType {
522                content_type: "text/plain".into(),
523                schema: Some(SchemaValue::new(SchemaKind::String)),
524            },
525        );
526        assert_eq!(
527            operation.request_schema().unwrap().kind.reference_name(),
528            Some("Input")
529        );
530        assert_eq!(
531            operation.success_schema().unwrap().kind.reference_name(),
532            Some("Output")
533        );
534    }
535
536    #[test]
537    fn missing_or_empty_success_never_uses_an_error_schema() {
538        let mut operation = Operation {
539            responses: vec![OperationResponse::json(
540                "default",
541                SchemaValue::reference("Error"),
542            )],
543            ..Operation::default()
544        };
545        assert!(operation.success_schema().is_none());
546        operation.responses.insert(
547            0,
548            OperationResponse {
549                status: "204".into(),
550                description: None,
551                media_types: vec![],
552            },
553        );
554        assert!(operation.success_schema().is_none());
555        assert!(operation.request_schema().is_none());
556    }
557
558    #[test]
559    fn preserves_a_recursive_discriminated_union() {
560        let value = SchemaValue {
561            nullable: true,
562            discriminator: Some(Discriminator {
563                property_name: "kind".into(),
564                mapping: BTreeMap::from([("cat".into(), "#/components/schemas/Cat".into())]),
565            }),
566            ..SchemaValue::new(SchemaKind::OneOf {
567                variants: vec![SchemaValue::reference("#/components/schemas/Cat")],
568            })
569        };
570        let round_trip: SchemaValue =
571            serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap();
572        assert_eq!(round_trip, value);
573        assert_eq!(value.kind.reference_name(), None);
574    }
575}
576
577#[cfg(test)]
578mod method_tests {
579    use super::*;
580    #[test]
581    fn custom_methods_preserve_case_and_string_serialization() {
582        for token in ["GET", "COPY", "x-Custom", "X!#$%&'*+-.^_`|~"] {
583            let method = HttpMethod::parse(token).unwrap();
584            assert_eq!(method.as_str(), token);
585            let encoded = serde_json::to_string(&method).unwrap();
586            assert_eq!(
587                serde_json::from_str::<HttpMethod>(&encoded).unwrap(),
588                method
589            );
590        }
591        for token in [
592            "",
593            "BAD METHOD",
594            "BAD\r\nMETHOD",
595            "Méthod",
596            "X/Path",
597            "get",
598            "pOst",
599        ] {
600            assert!(HttpMethod::parse(token).is_err());
601        }
602        assert!(HttpMethod::parse(&"X".repeat(257)).is_err());
603    }
604}