(ns ird.spec-check
"Validate a parsed API response against the OpenAPI schema the committed
`spec/openapi.json` declares for it.
This is deliberately small: it checks the properties that actually catch
drift between the spec and what the API returns — declared types,
`required` keys, `enum` membership, `oneOf` alternatives, and — because
every object in this spec is `additionalProperties: false` — fields the
server sends that the schema does not list. Anything it does not
understand it passes."
(:require
[clojure.string :as str]))
(defn- type-name
"How VALUE would be spelled as an OpenAPI type, for problem messages."
[value]
(cond (nil? value) "null"
(string? value) "string"
(boolean? value) "boolean"
(integer? value) "integer"
(number? value) "number"
(sequential? value) "array"
(map? value) "object"
:else (str (class value))))
(defn- type-matches?
[declared value]
(case declared
"object" (map? value)
"array" (sequential? value)
"string" (string? value)
"integer" (integer? value)
"number" (number? value)
"boolean" (boolean? value)
"null" (nil? value)
true))
(declare check)
(defn- check-node
[schema value path]
(let [{:strs [type properties required items enum oneOf additionalProperties]} schema]
(cond
oneOf
(when-not (some #(empty? (check % value path)) oneOf)
[(str path " matches none of oneOf "
(pr-str (mapv #(get % "type") oneOf))
" — got " (type-name value))])
(and enum (not (contains? (set enum) value)))
[(str path " = " (pr-str value) " is not in the declared enum "
(pr-str (vec enum)))]
(and type (not (type-matches? type value)))
[(str path " is declared " type " but the API returned " (type-name value))]
:else
(concat
(when (and (= type "object") (map? value))
(for [r (or required []) :when (not (contains? value (keyword r)))]
(str path "." r " is required by the spec but absent from the response")))
;; the inverse, and the one that finds under-documented responses:
;; additionalProperties:false makes an unlisted field a violation
(when (and (= type "object") (map? value) (false? additionalProperties) properties)
(for [k (keys value) :when (not (contains? properties (name k)))]
(str path "." (name k) " is returned by the API but absent from the spec")))
(when (and properties (map? value))
(mapcat (fn [[pname pschema]]
(when (contains? value (keyword pname))
(check pschema (get value (keyword pname))
(str path "." pname))))
properties))
;; every row, never a sample: a value that appears only in rare rows
;; (an uncommon action type, say) is exactly the one a schema omits
(when (and items (sequential? value))
(mapcat #(check items %2 (str path "[" %1 "]")) (range) value))))))
(defn check
"Problems found validating VALUE against SCHEMA, as a vector of
human-readable strings; empty when the response conforms. PATH prefixes
each message so a problem can be located in a nested body."
[schema value path]
(if (or (nil? schema) (empty? schema))
[]
(vec (remove nil? (check-node schema value path)))))
(defn response-schema
"The schema SPEC declares for METHOD PATH's STATUS response, or nil when
the spec documents no JSON body for it."
[spec method path status]
(get-in spec ["paths" path (name method) "responses" (str status)
"content" "application/json" "schema"]))