Skip to main content

forge_ir/
lib.rs

1//! OpenAPI Forge intermediate representation.
2//!
3//! These types are the canonical Rust shape of the IR. They mirror the WIT
4//! definitions in `wit/ir.wit` exactly. The `forge-ir-bindgen` crate handles
5//! conversion to and from the WIT-generated representation that crosses the
6//! component boundary.
7//!
8//! Pre-1.0 the IR is unstable: every change is a breaking change, and there is
9//! no `api-version` field. Plugins built against a different `forge-ir`
10//! version will fail at component load time with a WIT type error.
11//!
12//! See `docs/ir-spec.md` for the full contract.
13
14#![forbid(unsafe_code)]
15
16pub mod diagnostic;
17pub mod operation;
18#[cfg(any(test, feature = "proptest"))]
19pub mod proptest_util;
20pub mod security;
21pub mod types;
22pub mod value;
23
24use serde::{Deserialize, Serialize};
25
26pub use diagnostic::{Diagnostic, FixEdit, FixSuggestion, RelatedInfo, Severity, SpecLocation};
27pub use operation::{
28    Body, BodyContent, Encoding, Header, HttpMethod, Operation, Parameter, ParameterStyle,
29    Response, ResponseStatus,
30};
31pub use security::{
32    ApiKeyLocation, ApiKeyScheme, OAuth2Flow, OAuth2FlowKind, OAuth2Scheme, SecurityRequirement,
33    SecurityScheme, SecuritySchemeKind,
34};
35pub use types::{
36    AdditionalProperties, ArrayConstraints, ArrayType, EnumIntType, EnumIntValue, EnumStringType,
37    EnumStringValue, IntKind, NamedType, ObjectConstraints, ObjectType, PrimitiveConstraints,
38    PrimitiveKind, PrimitiveType, Property, TypeDef, TypeRef, NULL_ID,
39};
40pub use types::{Discriminator, UnionKind, UnionType, UnionVariant};
41pub use value::{Value, ValueRef};
42
43/// Top-level IR document.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct Ir {
46    pub info: ApiInfo,
47    /// Sorted by `id` for determinism.
48    pub operations: Vec<Operation>,
49    /// Topologically sorted; every `TypeRef` resolves to one of these by `id`.
50    pub types: Vec<NamedType>,
51    pub security_schemes: Vec<SecurityScheme>,
52    pub servers: Vec<Server>,
53    /// OpenAPI 3.1+ inbound webhooks. Each entry pairs the spec's
54    /// `webhooks.<name>` map key (the routing identifier) with the
55    /// path item's operations. Sorted by name for determinism.
56    /// Generators that only emit outbound clients can ignore this
57    /// field; webhook-handler generators dispatch on `Webhook.name`.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub webhooks: Vec<Webhook>,
60    /// Root-level `externalDocs`. Per-operation and per-schema slots
61    /// live on `Operation` / `NamedType` respectively.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub external_docs: Option<ExternalDocs>,
64    /// Top-level `tags` array, walked into structured records. Sorted
65    /// by `name` for determinism. `Operation.tags` stays a flat list
66    /// of names that reference into this list.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub tags: Vec<Tag>,
69    /// OpenAPI 3.1+ `jsonSchemaDialect` — declares which JSON Schema
70    /// draft the document's schemas conform to. Carried verbatim
71    /// (URL string); the parser does not validate or switch dialects
72    /// based on it. Generators that care can read it; most ignore it.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub json_schema_dialect: Option<String>,
75    /// OpenAPI 3.2 `$self` — the document's canonical URI for
76    /// base-URI resolution per RFC 3986. The parser captures it
77    /// verbatim; full base-URI semantics for external-`$ref`
78    /// resolution land in #93.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub self_url: Option<String>,
81    /// Pool of every structured `Value` referenced from elsewhere in
82    /// the IR (defaults, examples, link parameters, extensions,
83    /// constraint bounds). Compound `Value::List` / `Value::Object`
84    /// arms hold `ValueRef` indices into this list — see ADR-0007's
85    /// amendment and `crates/forge-ir/src/value.rs` for the design.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub values: Vec<Value>,
88}
89
90/// Top-level `tags[]` entry. Generators surface `description` and
91/// `summary` as group-level docs and use `parent` (3.2) to render
92/// nested operation menus.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Tag {
95    pub name: String,
96    /// OAS 3.2 `summary` — short single-line label.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub summary: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub description: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub external_docs: Option<ExternalDocs>,
103    /// OAS 3.2 `parent` — name of another tag this one nests under.
104    /// The parser warns and drops the parent reference (rather than the
105    /// entire tag) if it doesn't match a declared tag.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub parent: Option<String>,
108    /// OAS 3.2 `kind` — free-form classifier (e.g. `"audience"`,
109    /// `"channel"`). Generators that don't model it can ignore.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub kind: Option<String>,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub extensions: Vec<(String, ValueRef)>,
114}
115
116/// OAS ExternalDocumentation Object. `url` is required; `description`
117/// is CommonMark-flavoured prose.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ExternalDocs {
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub description: Option<String>,
122    pub url: String,
123}
124
125/// OpenAPI 3.1+ inbound webhook entry. The spec keys webhooks under a
126/// map name (`newPet`, `deletedPet`); that name is the routing
127/// identifier a webhook-handler generator dispatches on. A single
128/// path item can hold multiple HTTP-method operations, all sharing
129/// the same name.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct Webhook {
132    pub name: String,
133    /// Operations declared on the path item. Walked through the same
134    /// `parse_path_item` machinery used for top-level `paths`.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub operations: Vec<Operation>,
137}
138
139/// OAS Callback Object — describes out-of-band requests the API makes
140/// back to the caller. Used heavily by event-driven and webhook APIs.
141///
142/// The OAS shape is `callbacks: { <name>: { <expression>: PathItem } }`
143/// (a name maps to a map of runtime expressions, each pointing to a
144/// path item). The IR flattens this: each `Callback` carries one
145/// (name, expression) pair plus the ids of the operations the path
146/// item declared. A callback name with multiple expressions becomes
147/// multiple `Callback` entries with the same name.
148///
149/// `operation_ids` reference into [`Ir::operations`] — callback path-
150/// item operations live in the same flat list as top-level paths so
151/// the WIT shape stays non-recursive. OAS operationId uniqueness is
152/// API-wide, so this is consistent with the spec.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct Callback {
155    pub name: String,
156    /// Runtime expression keyed by the path-item entry, e.g.
157    /// `{$request.body#/callbackUrl}`. Verbatim from the spec.
158    pub expression: String,
159    /// Ids referencing into [`Ir::operations`].
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub operation_ids: Vec<String>,
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub extensions: Vec<(String, ValueRef)>,
164}
165
166/// OAS Link Object — HATEOAS-style "given this response, here's how to
167/// call the next operation". Carried in a `Vec<(String, Link)>` on
168/// `Response.links` (named, ordered).
169///
170/// Per OAS, `operation_ref` and `operation_id` are mutually exclusive.
171/// The parser keeps the first one declared if both appear.
172///
173/// `parameters` and `request_body` carry OAS *runtime expressions*
174/// (e.g. `$response.body#/id`). The IR stores them as `ValueRef`s
175/// indexing into [`Ir::values`]; compound expressions are now
176/// preserved via the value pool.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub struct Link {
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub operation_ref: Option<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub operation_id: Option<String>,
183    /// Map of parameter name → runtime expression / scalar literal.
184    /// Order is preserved.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub parameters: Vec<(String, ValueRef)>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub request_body: Option<ValueRef>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub description: Option<String>,
191    /// Per-link `server` override (rare).
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub server: Option<Server>,
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub extensions: Vec<(String, ValueRef)>,
196}
197
198/// OAS Schema Object's `xml` block: governs how the schema serializes
199/// to XML — element name override, namespace, prefix, attribute-vs-
200/// element placement, array wrapping. No in-tree generator currently
201/// emits XML clients; the IR carries the data so a future XML-capable
202/// generator can consume it.
203#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
204pub struct XmlObject {
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub name: Option<String>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub namespace: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub prefix: Option<String>,
211    /// `true` ⇒ render as XML attribute on the parent element;
212    /// `false` ⇒ render as child element. Defaults to `false`.
213    #[serde(default)]
214    pub attribute: bool,
215    /// Array-only: `true` ⇒ wrap the array in a parent element
216    /// (`<wrapper><item/><item/></wrapper>`). Defaults to `false`.
217    #[serde(default)]
218    pub wrapped: bool,
219    /// OAS 3.2 `text` — `true` ⇒ render the value as element text
220    /// content rather than a child element or attribute. Defaults to
221    /// `false`.
222    #[serde(default)]
223    pub text: bool,
224    /// OAS 3.2 `ordered` — array-only: `true` ⇒ element order is
225    /// significant (consumers must preserve it). Defaults to `false`.
226    #[serde(default)]
227    pub ordered: bool,
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub extensions: Vec<(String, ValueRef)>,
230}
231
232/// OAS Example Object. Carried in a `Vec<(String, Example)>` on
233/// `Parameter` / `BodyContent` / `NamedType` (named, ordered).
234/// 3.0 specs that declare a single bare `example` (no name) are
235/// stored under the synthetic key `"_default"` so generators have
236/// one shape to read.
237///
238/// `value` is the inline literal — a `ValueRef` indexing into the IR's
239/// value pool. `external_value` is the spec's URL escape hatch and is
240/// mutually exclusive with `value`; the parser warns and keeps `value`
241/// when both are declared.
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct Example {
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub summary: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub description: Option<String>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub value: Option<ValueRef>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub external_value: Option<String>,
252    /// OAS 3.2 `dataValue` — the parsed/decoded form of the example.
253    /// Spec splits the 3.0/3.1 `value` into `dataValue` (parsed) and
254    /// `serializedValue` (wire form) so generators can pick the
255    /// representation that matches their language. `ValueRef` indexes
256    /// into [`Ir::values`].
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub data_value: Option<ValueRef>,
259    /// OAS 3.2 `serializedValue` — the wire form as a string (e.g. the
260    /// JSON text, urlencoded body). Mutually exclusive with
261    /// `external_value`.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub serialized_value: Option<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267pub struct ApiInfo {
268    pub title: String,
269    pub version: String,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub description: Option<String>,
272    /// Short OpenAPI-3.1 summary line, distinct from `description`.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub summary: Option<String>,
275    /// URL pointing to the API's terms of service.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub terms_of_service: Option<String>,
278    /// `info.contact` block (any of `name` / `url` / `email`).
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub contact: Option<Contact>,
281    /// `info.license.name` — required by OAS when `license` is present.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub license_name: Option<String>,
284    /// `info.license.url` — mutually exclusive with `license.identifier`
285    /// in OAS 3.1+, but kept independent here so 3.0 specs round-trip.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub license_url: Option<String>,
288    /// SPDX license identifier (3.1 `info.license.identifier`).
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub license_identifier: Option<String>,
291    /// `x-*` extensions declared on the info object. Compound
292    /// extensions drop with `parser/W-EXTENSION-DROPPED`.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub extensions: Vec<(String, ValueRef)>,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct Contact {
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub name: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub url: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub email: Option<String>,
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct Server {
309    pub url: String,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub description: Option<String>,
312    /// OAS 3.2 `name` — short label distinct from `description`,
313    /// surfaced by tooling that displays multiple servers in a picker
314    /// UI. Carried verbatim; absent on 3.0 / 3.1 specs.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub name: Option<String>,
317    /// Tuples preserve declared order across the WIT boundary.
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub variables: Vec<(String, ServerVariable)>,
320    /// `x-*` extensions declared on the server object. Compound
321    /// extensions drop with `parser/W-EXTENSION-DROPPED`.
322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
323    pub extensions: Vec<(String, ValueRef)>,
324}
325
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub struct ServerVariable {
328    pub default: String,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub r#enum: Option<Vec<String>>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub description: Option<String>,
333    /// `x-*` extensions declared on the server-variable object.
334    /// Compound extensions drop with `parser/W-EXTENSION-DROPPED`.
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub extensions: Vec<(String, ValueRef)>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct PluginInfo {
341    pub name: String,
342    pub version: String,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum LogLevel {
348    Trace,
349    Debug,
350    Info,
351    Warn,
352    Error,
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn minimal_ir() -> Ir {
360        Ir {
361            info: ApiInfo {
362                title: "test".into(),
363                version: "0".into(),
364                description: None,
365                summary: None,
366                terms_of_service: None,
367                contact: None,
368                license_name: None,
369                license_url: None,
370                license_identifier: None,
371                extensions: vec![],
372            },
373            operations: vec![],
374            types: vec![],
375            security_schemes: vec![],
376            servers: vec![],
377            webhooks: vec![],
378            external_docs: None,
379            tags: vec![],
380            json_schema_dialect: None,
381            self_url: None,
382            values: vec![],
383        }
384    }
385
386    #[test]
387    fn json_roundtrip_minimal() {
388        let ir = minimal_ir();
389        let json = serde_json::to_string(&ir).unwrap();
390        let back: Ir = serde_json::from_str(&json).unwrap();
391        pretty_assertions::assert_eq!(ir, back);
392    }
393}