Skip to main content

kynos_openapi/validate/
violation.rs

1//! What a validator reports: how bad it is, where it is, and what is wrong.
2
3/// How seriously to take a [`Violation`].
4#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
5pub enum Severity {
6    /// The description is invalid, or would mislead a consumer.
7    Error,
8    /// The description is valid but weaker than it could be.
9    Warning,
10}
11
12/// A single problem found in a document.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Violation {
15    /// A JSON-Pointer-like path to the offending object.
16    pub location: String,
17    /// How seriously to take it.
18    pub severity: Severity,
19    /// What is wrong.
20    pub error: SpecError,
21}
22
23impl Violation {
24    pub(in crate::validate) fn error(location: impl Into<String>, error: SpecError) -> Self {
25        Self {
26            location: location.into(),
27            severity: Severity::Error,
28            error,
29        }
30    }
31
32    pub(in crate::validate) fn warning(location: impl Into<String>, error: SpecError) -> Self {
33        Self {
34            location: location.into(),
35            severity: Severity::Warning,
36            error,
37        }
38    }
39}
40
41impl std::fmt::Display for Violation {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let label = match self.severity {
44            Severity::Error => "error",
45            Severity::Warning => "warning",
46        };
47        write!(f, "{label} at {}: {}", self.location, self.error)
48    }
49}
50
51/// A violation is a line in a report rather than a link in a chain, so it
52/// carries no cause.
53///
54/// A validation run yields a list, and every consumer prints that list —
55/// `Router::validate` hands one back, and `Error::Invalid` renders one. `Display`
56/// is self-contained for that reason, so offering the [`SpecError`] it already
57/// names as a `source()` would make any reporter print the same sentence twice.
58///
59/// The implementation is still worth having: it is what lets a single violation
60/// be boxed, returned through `?`, or downcast back out of a `dyn Error`.
61impl std::error::Error for Violation {}
62
63/// Escapes one map key for use as a JSON Pointer token, per RFC 6901.
64///
65/// Every `paths` key contains a `/`, so a location that embeds one unescaped
66/// reads as several tokens and resolves against nothing. Shared so that the
67/// three places that build locations cannot disagree.
68pub(crate) fn pointer_token(key: &str) -> String {
69    key.replace('~', "~0").replace('/', "~1")
70}
71
72/// A way in which a document fails to conform.
73#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
74#[non_exhaustive]
75pub enum SpecError {
76    /// The same `operationId` was used more than once.
77    #[error("`operationId` `{operation_id}` is not unique; it is also used at {first}")]
78    DuplicateOperationId {
79        /// The repeated identifier.
80        operation_id: String,
81        /// Where it was first seen.
82        first: String,
83    },
84
85    /// Two path templates differ only in the names of their variables.
86    #[error(
87        "path `{template}` is the same path as `{existing}`; templates that differ \
88         only in variable name are identical"
89    )]
90    DuplicatePathTemplate {
91        /// The template being added.
92        template: String,
93        /// The template already present.
94        existing: String,
95    },
96
97    /// A `paths` key is not a legal path template.
98    ///
99    /// Reachable only for a description read from somewhere else: a template
100    /// Kynos constructs is checked when it is parsed.
101    ///
102    /// `reason` holds the parse failure itself rather than its text, so a caller
103    /// can match on which rule the key broke. It is interpolated rather than
104    /// declared as a `#[source]`: a violation is rendered in a list, so its
105    /// message has to be self-contained, and a cause could then only repeat it.
106    #[error("`{template}` is not a legal path template: {reason}")]
107    InvalidPathTemplate {
108        /// The offending key.
109        template: String,
110        /// Why it is not one.
111        reason: crate::model::paths::template::InvalidPathTemplate,
112    },
113
114    /// A path template variable has no corresponding parameter.
115    #[error("path template variable `{name}` has no matching `in: path` parameter")]
116    UndeclaredPathVariable {
117        /// The variable named in the template.
118        name: String,
119    },
120
121    /// A path parameter does not appear in the path template.
122    #[error("`in: path` parameter `{name}` does not appear in the path template")]
123    UnusedPathParameter {
124        /// The declared parameter.
125        name: String,
126    },
127
128    /// A path parameter was not marked required.
129    #[error("`in: path` parameter `{name}` must set `required: true`")]
130    PathParameterNotRequired {
131        /// The offending parameter.
132        name: String,
133    },
134
135    /// Two parameters share a name and location.
136    #[error("parameter `{name}` in `{location}` is declared more than once")]
137    DuplicateParameter {
138        /// The parameter name.
139        name: String,
140        /// Where it is carried.
141        location: String,
142    },
143
144    /// An interceptor's short circuit declared statuses its responses do not
145    /// describe, or the reverse.
146    ///
147    /// Only reachable from a hand-written `ShortCircuit`; the one the
148    /// `ApiError` derive emits comes from the same declaration as the
149    /// responses and cannot disagree with them.
150    #[error(
151        "`{name}` declares statuses {declared:?} as a short circuit but describes {described:?}"
152    )]
153    ShortCircuitMismatch {
154        /// The short-circuit type.
155        name: String,
156        /// What its `STATUSES` const claimed.
157        declared: Vec<u16>,
158        /// What its `Responses` actually described.
159        described: Vec<u16>,
160    },
161
162    /// A serialization style is not legal at a parameter's location.
163    #[error("style `{style}` may not be used with `in: {location}`")]
164    IllegalStyle {
165        /// The style used.
166        style: String,
167        /// The location it was used at.
168        location: String,
169    },
170
171    /// A header was declared as a parameter despite being ignored.
172    #[error(
173        "`{name}` must not be declared as a header parameter: the specification says \
174         such a definition is ignored, so declaring it misdescribes the API"
175    )]
176    IgnoredHeaderParameter {
177        /// The offending header name.
178        name: String,
179    },
180
181    /// A header was declared in a `headers` map despite being ignored.
182    ///
183    /// The parameter-side counterpart is [`SpecError::IgnoredHeaderParameter`].
184    #[error(
185        "`{name}` must not be declared here: the media type is stated separately, so \
186         the specification says such a definition is ignored"
187    )]
188    IgnoredHeader {
189        /// The offending header name.
190        name: String,
191    },
192
193    /// An operation declared no responses.
194    #[error("an operation must declare at least one response")]
195    NoResponses,
196
197    /// `encoding` was declared beside `prefixEncoding` or `itemEncoding`.
198    ///
199    /// 3.2 makes the three mutually exclusive: the first names properties, the
200    /// other two name array positions, and an object cannot be both.
201    ///
202    /// Gated, because the two fields it conflicts with are 3.2's — under 3.1
203    /// there is nothing for `encoding` to conflict with, so a variant that
204    /// existed there would be one no document could ever provoke.
205    #[cfg(feature = "openapi32")]
206    #[error(
207        "`encoding` describes named properties and `prefixEncoding`/`itemEncoding` describe array \
208         positions; a media type declares one or the other, never both"
209    )]
210    ConflictingEncoding,
211
212    /// A response carried no description, which 3.1 requires.
213    ///
214    /// 3.2 makes it optional, so this is raised only when validating against
215    /// 3.1. The model holds `Option<String>` because a 3.2 document may state
216    /// only a summary; the version is what decides whether that is legal.
217    #[error("a response must have a description under OpenAPI 3.1")]
218    MissingResponseDescription,
219
220    /// A component key used characters the specification forbids.
221    #[error("`{name}` is not a valid component name: expected only `A-Z a-z 0-9 . - _`")]
222    InvalidComponentName {
223        /// The offending key.
224        name: String,
225    },
226
227    /// Two tags share a name.
228    #[error("tag `{name}` is declared more than once")]
229    DuplicateTag {
230        /// The repeated tag name.
231        name: String,
232    },
233
234    /// A tag named a parent that does not exist.
235    #[error("tag `{name}` names parent `{parent}`, which is not declared")]
236    UnknownTagParent {
237        /// The tag with the parent.
238        name: String,
239        /// The parent that does not exist.
240        parent: String,
241    },
242
243    /// A chain of tag parents forms a cycle.
244    #[error("tag `{name}` is part of a parent cycle")]
245    TagParentCycle {
246        /// A tag on the cycle.
247        name: String,
248    },
249
250    /// A security requirement named a scheme that is not declared.
251    #[error("security requirement `{name}` does not name a declared security scheme")]
252    UnknownSecurityScheme {
253        /// The name used in the requirement.
254        name: String,
255    },
256
257    /// An operation used a tag with no metadata.
258    #[error("operation uses tag `{name}`, which has no metadata in the document `tags`")]
259    UndocumentedTag {
260        /// The tag used.
261        name: String,
262    },
263
264    /// A server variable's `enum` was empty.
265    #[error("server variable `{name}` declares an empty `enum`")]
266    EmptyServerVariableEnum {
267        /// The variable name.
268        name: String,
269    },
270
271    /// A server variable's default was not among its permitted values.
272    #[error("server variable `{name}` has a default that is not in its `enum`")]
273    ServerVariableDefaultNotInEnum {
274        /// The variable name.
275        name: String,
276    },
277
278    /// An extension field name was malformed or reserved.
279    #[error(
280        "`{name}` is not a usable extension name: expected an `x-` prefix, not `x-oai-`/`x-oas-`"
281    )]
282    InvalidExtensionName {
283        /// The offending field name.
284        name: String,
285    },
286
287    /// A schema was deliberately left unconstrained.
288    ///
289    /// Located at the schema itself: one carrying the `x-kynos-unchecked`
290    /// annotation wherever it is nested, or the permissive schema `true` where
291    /// it is a media type's own `schema` or `itemSchema`. A `$ref` is not
292    /// followed, so a component is reported once, where it is defined.
293    #[error(
294        "this schema is deliberately unconstrained, so the description makes no claim about \
295         what it describes"
296    )]
297    UncheckedSchema,
298
299    /// The description does not fully describe the service.
300    #[error("this description omits part of the service and is not authoritative")]
301    NotAuthoritative,
302
303    /// An operation is emitted but is covered by an `unchecked` waiver.
304    ///
305    /// It is still described; what it does is no longer verified.
306    #[error(
307        "this operation is covered by an `unchecked` waiver ({}), so its description is not \
308         verified",
309        reasons.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
310    )]
311    OpaqueOperation {
312        /// Why the waiver was needed.
313        reasons: Vec<crate::annotation::OpaqueReason>,
314    },
315
316    /// A route is served but has no path template that could express it.
317    #[error(
318        "`{pattern}` is served but no path template can express it, so it has no `paths` entry"
319    )]
320    OpaqueRoute {
321        /// The router's matching pattern, verbatim.
322        pattern: String,
323    },
324
325    /// The description is opaque somewhere but does not say so at the root.
326    ///
327    /// The document-level stamp is the one-glance signal a consumer reads
328    /// before deciding whether to trust anything else, so a description that
329    /// omits it while carrying an opaque operation or route is worse than one
330    /// that is honestly incomplete.
331    #[error(
332        "this description contains opaque operations or routes but is not marked \
333         non-authoritative"
334    )]
335    AuthorityNotStamped,
336
337    /// A Kynos annotation was present but not in the shape Kynos emits.
338    ///
339    /// `detail` is text where
340    /// [`InvalidPathTemplate`](Self::InvalidPathTemplate)'s `reason` is a value,
341    /// and the asymmetry is forced rather than chosen: this cause is a
342    /// `serde_json::Error`, which is neither `Clone` nor `PartialEq`, so keeping
343    /// it would cost the derives every other value in the validation model has.
344    #[error("`{name}` is present but is not in the form Kynos emits: {detail}")]
345    MalformedAnnotation {
346        /// The offending field name.
347        name: String,
348        /// What went wrong reading it.
349        detail: String,
350    },
351
352    /// The document declared nothing at all.
353    #[error("a document must declare at least one of `paths`, `components` or `webhooks`")]
354    EmptyDocument,
355
356    /// The document uses constructs that only OpenAPI 3.2 can express.
357    #[error("cannot emit as OpenAPI 3.1: {} 3.2-only construct(s) in use: {}", blockers.len(), blockers.join(", "))]
358    RequiresV3_2 {
359        /// Locations of the constructs standing in the way.
360        blockers: Vec<String>,
361    },
362}