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}