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