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}