Skip to main content

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;