|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'jsonschema_rs' |
| 4 | + |
| 5 | +module OpenapiFirst |
| 6 | + module Schema |
| 7 | + # An opt-in schema validation backend powered by the {https://rubygems.org/gems/jsonschema_rs jsonschema_rs} |
| 8 | + # gem (a Ruby binding to the Rust `jsonschema` crate). It is significantly faster than the default |
| 9 | + # {OpenapiFirst::Schema::JsonSchemerBackend}, but only supports OpenAPI 3.1 (which is JSON-Schema-2020-12 |
| 10 | + # compatible). OpenAPI 3.0 is not standard JSON Schema and is rejected at load time. |
| 11 | + # |
| 12 | + # Enable it globally: |
| 13 | + # OpenapiFirst.plugin :jsonschema_rs |
| 14 | + class JsonschemaRsBackend |
| 15 | + # The key under which a node's subtree is injected into a copy of its document so that internal $refs |
| 16 | + # resolve against the whole document while validation runs against just the subtree. |
| 17 | + SUBJECT_KEY = 'x-openapi-first-subject' |
| 18 | + |
| 19 | + # OpenAPI versions this backend can validate. 3.0 is unsupported (see class docs). |
| 20 | + # @return [Array<String>] |
| 21 | + def self.supported_openapi_versions = %w[3.1] |
| 22 | + |
| 23 | + # @param document [Hash] The resolved OpenAPI document (unused; accepted for backend interface parity). |
| 24 | + # @param filepath [String, nil] Unused; accepted for backend interface parity (per-node paths come from the node). |
| 25 | + # @param file_loader [OpenapiFirst::FileLoader] Loads referenced files from disk. |
| 26 | + def initialize(document:, filepath:, file_loader:) # rubocop:disable Lint/UnusedMethodArgument |
| 27 | + @file_loader = file_loader |
| 28 | + end |
| 29 | + |
| 30 | + # Build a schema validator for a node inside the document, keeping its $refs intact. |
| 31 | + # @param node [OpenapiFirst::RefResolver::Hash] |
| 32 | + # @param after_property_validation [Enumerable<#call>, nil] |
| 33 | + # @return [Validator] |
| 34 | + def build_node(node, after_property_validation: nil) |
| 35 | + Validator.new( |
| 36 | + raw: node.value, |
| 37 | + file_loader: @file_loader, |
| 38 | + context: node.context, |
| 39 | + filepath: node.filepath, |
| 40 | + dir: node.dir, |
| 41 | + properties: top_level_properties(node), |
| 42 | + after_property_validation: |
| 43 | + ) |
| 44 | + end |
| 45 | + |
| 46 | + # Build a schema validator for a self-contained schema Hash (no external context). |
| 47 | + # @param schema [Hash] |
| 48 | + # @return [Validator] |
| 49 | + def build_inline(schema) |
| 50 | + Validator.new(raw: schema, file_loader: @file_loader) |
| 51 | + end |
| 52 | + |
| 53 | + private |
| 54 | + |
| 55 | + def top_level_properties(node) |
| 56 | + node.resolved['properties'] || {} |
| 57 | + end |
| 58 | + |
| 59 | + # Wraps a compiled jsonschema_rs validator and adapts it to the openapi_first backend interface. |
| 60 | + # @visibility private |
| 61 | + class Validator |
| 62 | + def initialize(raw:, file_loader:, context: nil, filepath: nil, dir: nil, properties: nil, |
| 63 | + after_property_validation: nil) |
| 64 | + @raw = raw |
| 65 | + @file_loader = file_loader |
| 66 | + @context = context |
| 67 | + @filepath = filepath |
| 68 | + @dir = dir |
| 69 | + @properties = properties |
| 70 | + @after_property_validation = after_property_validation |
| 71 | + end |
| 72 | + |
| 73 | + # The raw schema Hash this validator was built from (used for default insertion). |
| 74 | + # @return [Hash] |
| 75 | + def value = @raw |
| 76 | + |
| 77 | + def valid?(data) |
| 78 | + validator.valid?(prepare(data)) |
| 79 | + end |
| 80 | + |
| 81 | + # @return [Array<Hash>] json_schemer-shaped error Hashes. |
| 82 | + def validate(data, access_mode: nil) |
| 83 | + prepared = prepare(data) |
| 84 | + errors = [] |
| 85 | + validator.each_error(prepared) do |error| |
| 86 | + next if access_mode && suppress_required?(error, access_mode) |
| 87 | + |
| 88 | + errors << map_error(error) |
| 89 | + end |
| 90 | + errors.concat(access_mode_errors(prepared, access_mode)) if access_mode |
| 91 | + errors |
| 92 | + end |
| 93 | + |
| 94 | + private |
| 95 | + |
| 96 | + def validator |
| 97 | + @validator ||= JSONSchema.validator_for(compiled_schema, **compile_options) |
| 98 | + end |
| 99 | + |
| 100 | + def compile_options |
| 101 | + options = { validate_formats: true } |
| 102 | + options[:retriever] = retriever if @context |
| 103 | + options |
| 104 | + end |
| 105 | + |
| 106 | + # When the schema lives inside a document, validate against a $ref into a copy of that document so |
| 107 | + # internal, nested and recursive $refs resolve. Self-contained schemas compile directly. |
| 108 | + def compiled_schema |
| 109 | + return @raw unless @context |
| 110 | + |
| 111 | + { '$ref' => "#{document_uri}#/#{SUBJECT_KEY}" } |
| 112 | + end |
| 113 | + |
| 114 | + def document_uri |
| 115 | + @document_uri ||= |
| 116 | + if @filepath |
| 117 | + "file://#{File.absolute_path(@filepath)}" |
| 118 | + else |
| 119 | + "file://#{@dir}/openapi-first-document" |
| 120 | + end |
| 121 | + end |
| 122 | + |
| 123 | + def served_document |
| 124 | + @served_document ||= @context.dup.tap { |doc| doc[SUBJECT_KEY] = @raw } |
| 125 | + end |
| 126 | + |
| 127 | + def retriever |
| 128 | + documents = { document_uri => served_document } |
| 129 | + lambda do |uri| |
| 130 | + uri = uri.to_s |
| 131 | + documents[uri] || load_external(uri) |
| 132 | + end |
| 133 | + end |
| 134 | + |
| 135 | + def load_external(uri) |
| 136 | + raise ArgumentError, "Unsupported reference URI: #{uri}" unless uri.start_with?('file://') |
| 137 | + |
| 138 | + @file_loader.load(uri.delete_prefix('file://')) |
| 139 | + end |
| 140 | + |
| 141 | + # Insert top-level property defaults and fire after_property_validation hooks, mirroring (at the top |
| 142 | + # level) what json_schemer does during traversal. Mutates and returns +data+. |
| 143 | + def prepare(data) |
| 144 | + return data unless @properties && data.is_a?(::Hash) |
| 145 | + |
| 146 | + insert_defaults(data) |
| 147 | + fire_after_property_validation(data) |
| 148 | + data |
| 149 | + end |
| 150 | + |
| 151 | + def insert_defaults(data) |
| 152 | + @properties.each do |name, subschema| |
| 153 | + next unless subschema.is_a?(::Hash) && subschema.key?('default') |
| 154 | + |
| 155 | + data[name] = subschema['default'] unless data.key?(name) |
| 156 | + end |
| 157 | + end |
| 158 | + |
| 159 | + def fire_after_property_validation(data) |
| 160 | + return unless @after_property_validation&.any? |
| 161 | + |
| 162 | + @properties.each do |name, subschema| |
| 163 | + next unless data.key?(name) |
| 164 | + |
| 165 | + @after_property_validation.each { |hook| hook.call(data, name, subschema, nil) } |
| 166 | + end |
| 167 | + end |
| 168 | + |
| 169 | + # In write access mode a readOnly property and in read access mode a writeOnly property is not required, |
| 170 | + # so suppress the corresponding top-level `required` error. |
| 171 | + def suppress_required?(error, access_mode) |
| 172 | + return false unless error.kind.name == 'required' |
| 173 | + |
| 174 | + subschema = @properties[error.kind.value[:property]] |
| 175 | + subschema.is_a?(::Hash) && subschema[access_mode == 'write' ? 'readOnly' : 'writeOnly'] == true |
| 176 | + end |
| 177 | + |
| 178 | + # A readOnly property must not appear in a request body (write) and a writeOnly property must not appear |
| 179 | + # in a response body (read). jsonschema_rs surfaces readOnly/writeOnly as annotations on present |
| 180 | + # properties, which we turn into errors at every depth. |
| 181 | + def access_mode_errors(data, access_mode) |
| 182 | + flag = access_mode == 'write' ? 'readOnly' : 'writeOnly' |
| 183 | + validator.evaluate(data).annotations.filter_map do |annotation| |
| 184 | + values = annotation[:annotations] |
| 185 | + next unless values.is_a?(::Hash) && values[flag] == true |
| 186 | + |
| 187 | + { |
| 188 | + 'data' => nil, |
| 189 | + 'data_pointer' => annotation[:instanceLocation], |
| 190 | + 'schema_pointer' => annotation[:schemaLocation], |
| 191 | + 'type' => flag, |
| 192 | + 'details' => nil, |
| 193 | + 'schema' => { flag => true } |
| 194 | + } |
| 195 | + end |
| 196 | + end |
| 197 | + |
| 198 | + def map_error(error) |
| 199 | + type, details, schema = translate(error.kind) |
| 200 | + { |
| 201 | + 'data' => error.instance, |
| 202 | + 'data_pointer' => error.instance_path_pointer, |
| 203 | + 'schema_pointer' => error.schema_path_pointer, |
| 204 | + 'type' => type, |
| 205 | + 'details' => details, |
| 206 | + 'schema' => schema |
| 207 | + } |
| 208 | + end |
| 209 | + |
| 210 | + # Map jsonschema_rs's generic error kinds to json_schemer's keyword vocabulary, reconstructing the |
| 211 | + # minimal schema/details that {OpenapiFirst::Schema::ValidationError#message} needs. |
| 212 | + def translate(kind) |
| 213 | + value = kind.value |
| 214 | + case kind.name |
| 215 | + when 'type' |
| 216 | + types = value[:types] |
| 217 | + single = types.length == 1 |
| 218 | + [single ? types.first : 'type', nil, { 'type' => single ? types.first : types }] |
| 219 | + when 'required' |
| 220 | + ['required', { 'missing_keys' => [value[:property]] }, nil] |
| 221 | + when 'pattern' then ['pattern', nil, { 'pattern' => value[:pattern] }] |
| 222 | + when 'format' then ['format', nil, { 'format' => value[:format] }] |
| 223 | + when 'const' then ['const', nil, { 'const' => value[:expected_value] }] |
| 224 | + when 'enum' then ['enum', nil, { 'enum' => value[:options] }] |
| 225 | + when 'minimum' then ['minimum', nil, { 'minimum' => value[:limit] }] |
| 226 | + when 'maximum' then ['maximum', nil, { 'maximum' => value[:limit] }] |
| 227 | + else [kind.name, nil, nil] |
| 228 | + end |
| 229 | + end |
| 230 | + end |
| 231 | + end |
| 232 | + end |
| 233 | +end |
0 commit comments