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}