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}