kynos_openapi/annotation/mod.rs
1//! The `x-kynos-*` annotations: what a waiver leaves on a description.
2//!
3//! Kynos only lets an application build an API it can describe. Where an
4//! escape hatch is taken anyway, the description does not quietly lose the
5//! affected part of the service — it records that the part exists and that
6//! Kynos did not verify it. These are the field names and shapes that record
7//! carries, so that a producer and a checker agree on it by construction
8//! rather than by convention.
9//!
10//! Two records, because there are two situations:
11//!
12//! | Situation | Record | Why |
13//! | --- | --- | --- |
14//! | A real operation on a real path, wrapped in something undeclared | [`Opaque`] on the operation | The path is true; only the behaviour is unverified |
15//! | A route no path template can express | [`OpaqueRoute`] on the document | Every `paths` key that could be minted would be a lie |
16//!
17//! [`NOT_AUTHORITATIVE_ANNOTATION`] summarizes both. It is derived — see
18//! [`Document::restamp_authority`] — never authored.
19
20use serde::{Deserialize, Serialize};
21
22use crate::model::{
23 document::Document,
24 paths::{item::PathItem, operation::Operation},
25 reference::RefOr,
26};
27
28/// The annotation marking a schema as deliberately unconstrained.
29///
30/// Kynos attaches this wherever a handler used the explicit permissive type, so
31/// that "this payload is unchecked" is visible in the published description
32/// rather than only in the Rust source.
33pub const UNCHECKED_SCHEMA_ANNOTATION: &str = "x-kynos-unchecked";
34
35/// The annotation marking one operation as emitted but unverified.
36///
37/// Carries an [`Opaque`]. The operation stays in `paths`: an omission is
38/// invisible to the consumer that trusts the description, which is strictly
39/// worse than a flag it can act on.
40pub const OPAQUE_OPERATION_ANNOTATION: &str = "x-kynos-opaque";
41
42/// The annotation listing routes no path template can express.
43///
44/// Carries an array of [`OpaqueRoute`] at the root of the document.
45pub const OPAQUE_ROUTES_ANNOTATION: &str = "x-kynos-opaque-routes";
46
47/// The annotation marking a description as not fully describing the service.
48///
49/// Derived, never authored: true exactly when some operation carries
50/// [`OPAQUE_OPERATION_ANNOTATION`] or some route is recorded under
51/// [`OPAQUE_ROUTES_ANNOTATION`]. [`Document::restamp_authority`] computes it.
52pub const NOT_AUTHORITATIVE_ANNOTATION: &str = "x-kynos-document-not-authoritative";
53
54/// Why part of a service is not verifiably described.
55///
56/// Deliberately not `Copy`: the wire form has to survive a description written
57/// by a newer Kynos, which means carrying a reason this build does not know as
58/// [`Unrecognized`](OpaqueReason::Unrecognized) rather than failing to read the
59/// record at all.
60#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62#[non_exhaustive]
63pub enum OpaqueReason {
64 /// A layer of undeclared effect covers the operation.
65 ///
66 /// It may short-circuit, rewrite the body, or add headers, and its type
67 /// says nothing about which.
68 UntypedLayer,
69
70 /// The route's matching pattern is not a legal path template.
71 ///
72 /// A catch-all is the usual case: it matches a set of paths that no single
73 /// template describes.
74 UntypedRoute,
75
76 /// The route's handler declares neither its inputs nor its responses.
77 UntypedHandler,
78
79 /// The route leaves HTTP, so no version of the specification covers it.
80 ///
81 /// OpenAPI describes request/response semantics. A connection that has
82 /// upgraded away from HTTP has no vocabulary here, and inventing one would
83 /// produce an entry no consumer could act on.
84 ProtocolUpgrade,
85
86 /// The route serves a tree of files whose membership is not fixed.
87 ///
88 /// A catch-all like every other, so [`UntypedRoute`](Self::UntypedRoute)
89 /// would be true of it — but it reads identically to a business API someone
90 /// wildcarded, and the two deserve different amounts of alarm. A consumer
91 /// meeting this knows the undescribed part of the service is a directory of
92 /// files rather than an operation nobody wrote down, and a CI gate can
93 /// tolerate exactly this one.
94 StaticAssets,
95
96 /// A reason recorded by a version of Kynos that knows more than this one.
97 ///
98 /// Preserved verbatim so the record round-trips. An older reader must not
99 /// turn a description it merely does not fully understand into one it
100 /// reports as malformed -- and must not drop the reason when it writes the
101 /// document back out.
102 #[serde(untagged)]
103 Unrecognized(String),
104}
105
106impl OpaqueReason {
107 /// The reason as it is spelled in the description.
108 #[must_use]
109 pub fn as_str(&self) -> &str {
110 match self {
111 Self::UntypedLayer => "untyped-layer",
112 Self::UntypedRoute => "untyped-route",
113 Self::UntypedHandler => "untyped-handler",
114 Self::ProtocolUpgrade => "protocol-upgrade",
115 Self::StaticAssets => "static-assets",
116 Self::Unrecognized(reason) => reason,
117 }
118 }
119}
120
121impl std::fmt::Display for OpaqueReason {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(self.as_str())
124 }
125}
126
127/// The record a waiver leaves on one operation.
128///
129/// Serialized under [`OPAQUE_OPERATION_ANNOTATION`]. Marks the operation
130/// unverified; never removes it.
131#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
132#[non_exhaustive]
133pub struct Opaque {
134 /// Every reason recorded, in the order they were recorded, deduplicated.
135 pub reasons: Vec<OpaqueReason>,
136
137 /// Where the waiver was taken, for a human reading the description.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub note: Option<String>,
140}
141
142impl Opaque {
143 /// A marker carrying one reason.
144 #[must_use]
145 pub fn new(reason: OpaqueReason) -> Self {
146 Self {
147 reasons: vec![reason],
148 note: None,
149 }
150 }
151
152 /// Adds a reason, idempotently.
153 #[must_use]
154 pub fn with_reason(mut self, reason: OpaqueReason) -> Self {
155 self.add_reason(reason);
156 self
157 }
158
159 /// Records where the waiver was taken.
160 #[must_use]
161 pub fn with_note(mut self, note: impl Into<String>) -> Self {
162 self.note = Some(note.into());
163 self
164 }
165
166 /// Unions `other`'s reasons into this marker, keeping the first note.
167 pub fn absorb(&mut self, other: &Self) {
168 for reason in &other.reasons {
169 self.add_reason(reason.clone());
170 }
171 if self.note.is_none() {
172 self.note.clone_from(&other.note);
173 }
174 }
175
176 fn add_reason(&mut self, reason: OpaqueReason) {
177 if !self.reasons.contains(&reason) {
178 self.reasons.push(reason);
179 }
180 }
181
182 /// Whether `operation` carries the annotation at all.
183 ///
184 /// True even when the value is malformed, so that a description Kynos
185 /// cannot read is still treated as unverified rather than as clean.
186 #[must_use]
187 pub fn is_annotated(operation: &Operation) -> bool {
188 operation
189 .extensions
190 .get(OPAQUE_OPERATION_ANNOTATION)
191 .is_some()
192 }
193
194 /// Reads the marker from an operation.
195 ///
196 /// `Ok(None)` means the operation carries no marker. A reason this build
197 /// does not know is *not* an error — it round-trips as
198 /// [`OpaqueReason::Unrecognized`] — so an error here means the value was
199 /// hand-written into a shape Kynos never emits.
200 ///
201 /// # Errors
202 ///
203 /// Returns [`MalformedAnnotation`] when the annotation is present but
204 /// unreadable.
205 pub fn of(operation: &Operation) -> Result<Option<Self>, MalformedAnnotation> {
206 let Some(value) = operation.extensions.get(OPAQUE_OPERATION_ANNOTATION) else {
207 return Ok(None);
208 };
209 serde_json::from_value(value.clone())
210 .map(Some)
211 .map_err(|error| MalformedAnnotation::new(OPAQUE_OPERATION_ANNOTATION, &error))
212 }
213
214 /// Writes the marker onto an operation, merging with any already present.
215 ///
216 /// # Errors
217 ///
218 /// Returns [`MalformedAnnotation`] when the operation already carries an
219 /// unreadable marker, rather than replacing it. Overwriting would delete a
220 /// waiver someone recorded, which is the one thing this whole mechanism
221 /// exists to prevent.
222 ///
223 /// # Panics
224 ///
225 /// Panics only if this marker cannot be serialized, which the type makes
226 /// impossible.
227 pub fn apply_to(&self, operation: &mut Operation) -> Result<(), MalformedAnnotation> {
228 let mut merged = Self::of(operation)?.unwrap_or_default();
229 merged.absorb(self);
230 let value = serde_json::to_value(&merged).expect("an opaque marker is always serializable");
231 operation
232 .extensions
233 .insert(OPAQUE_OPERATION_ANNOTATION, value);
234 Ok(())
235 }
236}
237
238/// A route the description cannot express, recorded rather than dropped.
239///
240/// `pattern` is the router's own matching syntax, verbatim. It is deliberately
241/// not a [`PathTemplate`](crate::PathTemplate): minting a template for a
242/// catch-all would put a claim in `paths` that the service does not honour —
243/// either about the path, or about a parameter whose value always contains an
244/// unescaped `/`. A consumer gets something visible, greppable and diffable
245/// instead of a plausible lie.
246#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
247#[non_exhaustive]
248pub struct OpaqueRoute {
249 /// The router's matching pattern, verbatim.
250 pub pattern: String,
251
252 /// The literal prefix the pattern is anchored at, if any.
253 ///
254 /// Recorded so that a reader can tell which part of the URL space the
255 /// route claims without parsing the router's matching syntax.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub prefix: Option<String>,
258
259 /// The methods served, spelled as they appear on the wire.
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub methods: Vec<String>,
262
263 /// Why it cannot be expressed.
264 pub reason: OpaqueReason,
265
266 /// A human-readable note.
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub note: Option<String>,
269}
270
271impl OpaqueRoute {
272 /// Records a route under `pattern` that cannot be described.
273 #[must_use]
274 pub fn new(pattern: impl Into<String>, reason: OpaqueReason) -> Self {
275 Self {
276 pattern: pattern.into(),
277 prefix: None,
278 methods: Vec::new(),
279 reason,
280 note: None,
281 }
282 }
283
284 /// Records the literal prefix the pattern is anchored at.
285 #[must_use]
286 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
287 self.prefix = Some(prefix.into());
288 self
289 }
290
291 /// Records the methods served.
292 #[must_use]
293 pub fn with_methods<I, S>(mut self, methods: I) -> Self
294 where
295 I: IntoIterator<Item = S>,
296 S: Into<String>,
297 {
298 self.methods = methods.into_iter().map(Into::into).collect();
299 self
300 }
301
302 /// Records a human-readable note.
303 #[must_use]
304 pub fn with_note(mut self, note: impl Into<String>) -> Self {
305 self.note = Some(note.into());
306 self
307 }
308
309 /// Whether `document` carries the annotation at all.
310 ///
311 /// True even when the value is malformed, for the reason
312 /// [`Opaque::is_annotated`] gives.
313 #[must_use]
314 pub fn is_annotated(document: &Document) -> bool {
315 document.extensions.get(OPAQUE_ROUTES_ANNOTATION).is_some()
316 }
317
318 /// Reads every recorded route from a document.
319 ///
320 /// An absent annotation reads as an empty list, since recording nothing is
321 /// the same claim as recording an empty list.
322 ///
323 /// # Errors
324 ///
325 /// Returns [`MalformedAnnotation`] when the annotation is present but
326 /// unreadable. A reason this build does not know is not that case — it
327 /// round-trips as [`OpaqueReason::Unrecognized`].
328 pub fn all(document: &Document) -> Result<Vec<Self>, MalformedAnnotation> {
329 let Some(value) = document.extensions.get(OPAQUE_ROUTES_ANNOTATION) else {
330 return Ok(Vec::new());
331 };
332 serde_json::from_value(value.clone())
333 .map_err(|error| MalformedAnnotation::new(OPAQUE_ROUTES_ANNOTATION, &error))
334 }
335
336 /// Appends this record to a document.
337 ///
338 /// # Errors
339 ///
340 /// Returns [`MalformedAnnotation`] when the document already carries an
341 /// unreadable list, rather than replacing it. Appending by overwriting
342 /// would delete every route someone else recorded — silent loss of exactly
343 /// the record this mechanism exists to keep.
344 ///
345 /// # Panics
346 ///
347 /// Panics only if this record cannot be serialized, which the type makes
348 /// impossible.
349 pub fn append_to(&self, document: &mut Document) -> Result<(), MalformedAnnotation> {
350 let mut routes = Self::all(document)?;
351 routes.push(self.clone());
352 let value = serde_json::to_value(&routes).expect("an opaque route is always serializable");
353 document.extensions.insert(OPAQUE_ROUTES_ANNOTATION, value);
354 Ok(())
355 }
356}
357
358/// A Kynos annotation was present but not in the shape Kynos emits.
359#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
360#[error("`{name}` is present but is not in the form Kynos emits: {detail}")]
361pub struct MalformedAnnotation {
362 /// The offending field name.
363 pub name: String,
364 /// What went wrong reading it.
365 pub detail: String,
366}
367
368impl MalformedAnnotation {
369 fn new(name: &str, error: &serde_json::Error) -> Self {
370 Self {
371 name: name.to_owned(),
372 detail: error.to_string(),
373 }
374 }
375}
376
377/// Every operation reachable from one path item.
378///
379/// Callbacks are path items in their own right, and an operation inside one is
380/// as much part of the service as any other — so a waiver taken there has to be
381/// as visible. Boxed because the recursion is not otherwise expressible.
382fn item_operations(item: &PathItem) -> Box<dyn Iterator<Item = &Operation> + '_> {
383 let declared = item.operations().map(|(_, operation)| operation);
384 #[cfg(feature = "openapi32")]
385 let declared = declared.chain(item.additional_operations.values().map(Box::as_ref));
386
387 Box::new(declared.flat_map(|operation| {
388 std::iter::once(operation).chain(
389 operation
390 .callbacks
391 .values()
392 .filter_map(RefOr::as_item)
393 .flat_map(|callback| callback.items.values())
394 .filter_map(RefOr::as_item)
395 .flat_map(item_operations),
396 )
397 }))
398}
399
400/// Every operation in a document, wherever it is declared.
401fn operations(document: &Document) -> impl Iterator<Item = &Operation> {
402 document
403 .paths
404 .items
405 .values()
406 .chain(document.webhooks.values())
407 .chain(document.components.path_items.values())
408 .flat_map(item_operations)
409 .chain(
410 document
411 .components
412 .callbacks
413 .values()
414 .filter_map(RefOr::as_item)
415 .flat_map(|callback| callback.items.values())
416 .filter_map(RefOr::as_item)
417 .flat_map(item_operations),
418 )
419}
420
421impl Document {
422 /// Whether every operation and route in this document is verifiably
423 /// described.
424 ///
425 /// This is the property [`NOT_AUTHORITATIVE_ANNOTATION`] negates. Computing
426 /// it rather than reading the stamp is deliberate: the stamp is a summary a
427 /// consumer reads, not the fact itself. An annotation this build cannot
428 /// read counts as unclean, because the alternative is calling a description
429 /// authoritative on the strength of not understanding it.
430 #[must_use]
431 pub fn is_authoritative(&self) -> bool {
432 let no_opaque_routes = OpaqueRoute::all(self).is_ok_and(|routes| routes.is_empty());
433 no_opaque_routes && !operations(self).any(Opaque::is_annotated)
434 }
435
436 /// Brings [`NOT_AUTHORITATIVE_ANNOTATION`] into line with this document.
437 ///
438 /// Adds the stamp when something is opaque and removes it when nothing is,
439 /// so that a document edited after the fact cannot keep a stamp it no
440 /// longer earns — or lose one it does.
441 pub fn restamp_authority(&mut self) {
442 if self.is_authoritative() {
443 self.extensions.remove(NOT_AUTHORITATIVE_ANNOTATION);
444 } else {
445 self.extensions.insert(NOT_AUTHORITATIVE_ANNOTATION, true);
446 }
447 }
448}
449
450#[cfg(test)]
451mod tests;