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