openapi_first 1.0.0.beta4 → 1.0.0.beta5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c02c053192b6b39cb8f05acd35f7a257ee98b449c49f8ceeec4ac6e542f09e15
4
- data.tar.gz: c36d3598b263ebd3d1a9bd23e6aa0b66256890f68e2f70fe245ca932d2f004f0
3
+ metadata.gz: 8e6c1f8f1a6fffd91827a74f95b932bfd5be9d4a897f3704efd6313bac9e6be4
4
+ data.tar.gz: 59041cacdcb634bb25e5d23025c0f86afe97632918f1e10d6f67409d31ba5f9b
5
5
  SHA512:
6
- metadata.gz: e70192d7c2cb58734b8ebb6ccf53b2f243a98e78adbea271a3b0df1468f400d60080ead1ea68c07dd33bd9985d67de02ec4e0200e3ca0d49272a893c97611ffb
7
- data.tar.gz: c59e62cd24dd767330f7e9ce6ad96a9c13005a4498c39f3ccf10bc801b49b6bddccf3ac62f858ae3ecf56fa4c695b4ad010fea7292171f98b640dec968ea357d
6
+ metadata.gz: 1955264ba1b60f477123cd1bbb71a14d611d598664965548a9ebe3c6508d5ac6e205dfe971bc7c1ebe6b27da78a48f1bf5d27239c886a9b4aa7db303224e0cfc
7
+ data.tar.gz: 2f25b5944e546a6619c2be01462008d358a0a80140594b0906ca62e9b152fa97b2b8225d0c137acbe29c64b7732bbd00d3f35459cd0b771b2b38c409303adde8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.0.0.beta5
6
+
7
+ - Added: `OpenapiFirst::Config.default_options=` to set default options globally
8
+ - Added: You can define custom error responses by subclassing `OpenapiFirst::ErrorResponse` and register it via `OpenapiFirst::Plugins.register_error_response(name, MyCustomErrorResponse)`
9
+
5
10
  ## 1.0.0.beta4
6
11
 
7
12
  - Update json_schemer to version 2.0
data/Gemfile.lock CHANGED
@@ -1,19 +1,18 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (1.0.0.beta4)
4
+ openapi_first (1.0.0.beta5)
5
5
  hanami-router (~> 2.0.0)
6
6
  json_refs (~> 0.1, >= 0.1.7)
7
7
  json_schemer (~> 2.0.0)
8
- multi_json (~> 1.14)
9
- openapi_parameters (~> 0.2.2)
8
+ multi_json (~> 1.15)
9
+ openapi_parameters (>= 0.3.1, < 2.0)
10
10
  rack (>= 2.2, < 4.0)
11
11
 
12
12
  GEM
13
13
  remote: https://rubygems.org/
14
14
  specs:
15
15
  ast (2.4.2)
16
- base64 (0.1.1)
17
16
  diff-lcs (1.5.0)
18
17
  hana (1.3.7)
19
18
  hanami-router (2.0.2)
@@ -35,20 +34,20 @@ GEM
35
34
  mustermann-contrib (3.0.0)
36
35
  hansi (~> 0.2.0)
37
36
  mustermann (= 3.0.0)
38
- openapi_parameters (0.2.2)
37
+ openapi_parameters (0.3.1)
39
38
  rack (>= 2.2)
40
39
  zeitwerk (~> 2.6)
41
40
  parallel (1.23.0)
42
- parser (3.2.2.3)
41
+ parser (3.2.2.4)
43
42
  ast (~> 2.4.1)
44
43
  racc
45
- racc (1.7.1)
44
+ racc (1.7.3)
46
45
  rack (2.2.8)
47
46
  rack-test (2.1.0)
48
47
  rack (>= 1.3)
49
48
  rainbow (3.1.1)
50
- rake (13.0.6)
51
- regexp_parser (2.8.1)
49
+ rake (13.1.0)
50
+ regexp_parser (2.8.2)
52
51
  rexml (3.2.6)
53
52
  rspec (3.12.0)
54
53
  rspec-core (~> 3.12.0)
@@ -63,19 +62,18 @@ GEM
63
62
  diff-lcs (>= 1.2.0, < 2.0)
64
63
  rspec-support (~> 3.12.0)
65
64
  rspec-support (3.12.1)
66
- rubocop (1.56.3)
67
- base64 (~> 0.1.1)
65
+ rubocop (1.57.2)
68
66
  json (~> 2.3)
69
67
  language_server-protocol (>= 3.17.0)
70
68
  parallel (~> 1.10)
71
- parser (>= 3.2.2.3)
69
+ parser (>= 3.2.2.4)
72
70
  rainbow (>= 2.2.2, < 4.0)
73
71
  regexp_parser (>= 1.8, < 3.0)
74
72
  rexml (>= 3.2.5, < 4.0)
75
73
  rubocop-ast (>= 1.28.1, < 2.0)
76
74
  ruby-progressbar (~> 1.7)
77
75
  unicode-display_width (>= 2.4.0, < 3.0)
78
- rubocop-ast (1.29.0)
76
+ rubocop-ast (1.30.0)
79
77
  parser (>= 3.2.1.0)
80
78
  ruby-progressbar (1.13.0)
81
79
  ruby2_keywords (0.0.5)
@@ -83,9 +81,9 @@ GEM
83
81
  unf (~> 0.1.4)
84
82
  unf (0.1.4)
85
83
  unf_ext
86
- unf_ext (0.0.8.2)
87
- unicode-display_width (2.4.2)
88
- zeitwerk (2.6.11)
84
+ unf_ext (0.0.9)
85
+ unicode-display_width (2.5.0)
86
+ zeitwerk (2.6.12)
89
87
 
90
88
  PLATFORMS
91
89
  arm64-darwin-21
data/README.md CHANGED
@@ -28,11 +28,11 @@ It adds these fields to the Rack env:
28
28
 
29
29
  ### Options and defaults
30
30
 
31
- | Name | Possible values | Description | Default |
32
- | :------------- | --------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------- |
33
- | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
34
- | `raise_error:` | `false`, `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` instead of returning 4xx. | `false` (don't raise an exception) |
35
- | `error_response:`| `:default`, Your implementation of `ErrorResponse` | :default
31
+ | Name | Possible values | Description | Default |
32
+ | :---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------- |
33
+ | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
34
+ | `raise_error:` | `false`, `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` instead of returning 4xx. | `false` (don't raise an exception) |
35
+ | `error_response:` | `:default`, `:json_api`, Your implementation of `ErrorResponse` | :default |
36
36
 
37
37
  The error responses conform with [JSON:API](https://jsonapi.org).
38
38
 
@@ -40,7 +40,7 @@ Here's an example response body for a missing query parameter "search":
40
40
 
41
41
  ```json
42
42
  http-status: 400
43
- content-type: "application/vnd.api+json"
43
+ content-type: "application/json"
44
44
 
45
45
  {
46
46
  "errors": [
@@ -54,7 +54,6 @@ content-type: "application/vnd.api+json"
54
54
  }
55
55
  ```
56
56
 
57
-
58
57
  ### Parameters
59
58
 
60
59
  The `RequestValidation` middleware adds `env[OpenapiFirst::PARAMS]` (or `env['openapi.params']` ) with the converted query and path parameters. This only includes the parameters that are defined in the API description. It supports every [`style` and `explode` value as described](https://spec.openapis.org/oas/latest.html#style-examples) in the OpenAPI 3.0 and 3.1 specs. So you can do things these:
@@ -120,6 +119,17 @@ This middleware adds `env['openapi.operation']` which holds an instance of `Open
120
119
  | `raise_error:` | `false`, `true` | If set to true the middleware raises `OpenapiFirst::NotFoundError` when a path or method was not found in the API description. This is useful during testing to spot an incomplete API description. | `false` (don't raise an exception) |
121
120
  | `not_found:` | `:continue`, `:halt` | If set to `:continue` the middleware will not return 404 (405, 415), but just pass handling the request to the next middleware or application in the Rack stack. If combined with `raise_error: true` `raise_error` gets preference and an exception is raised. | `:halt` (return 4xx response) |
122
121
 
122
+ ## Global configuration
123
+
124
+ You can configure default options gobally via `OpenapiFirst::Config`:
125
+
126
+ ```ruby
127
+ OpenapiFirst::Config.default_options = {
128
+ error_response: :json_api,
129
+ request_validation_raise_error: true
130
+ }
131
+ ```
132
+
123
133
  ## Alternatives
124
134
 
125
135
  This gem is inspired by [committee](https://github.com/interagent/committee) (Ruby) and [connexion](https://github.com/zalando/connexion) (Python).
@@ -1,32 +1,42 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- openapi_first (1.0.0.beta3)
4
+ openapi_first (1.0.0.beta5)
5
5
  hanami-router (~> 2.0.0)
6
6
  json_refs (~> 0.1, >= 0.1.7)
7
7
  json_schemer (~> 2.0.0)
8
- multi_json (~> 1.14)
9
- openapi_parameters (~> 0.2.2)
8
+ multi_json (~> 1.15)
9
+ openapi_parameters (>= 0.3.1, < 2.0)
10
10
  rack (>= 2.2, < 4.0)
11
11
 
12
12
  GEM
13
13
  remote: https://rubygems.org/
14
14
  specs:
15
- activesupport (7.0.8)
15
+ activesupport (7.1.2)
16
+ base64
17
+ bigdecimal
16
18
  concurrent-ruby (~> 1.0, >= 1.0.2)
19
+ connection_pool (>= 2.2.5)
20
+ drb
17
21
  i18n (>= 1.6, < 2)
18
22
  minitest (>= 5.1)
23
+ mutex_m
19
24
  tzinfo (~> 2.0)
25
+ base64 (0.2.0)
20
26
  benchmark-ips (2.12.0)
21
27
  benchmark-memory (0.2.0)
22
28
  memory_profiler (~> 1)
29
+ bigdecimal (3.1.4)
23
30
  builder (3.2.4)
24
31
  committee (5.0.0)
25
32
  json_schema (~> 0.14, >= 0.14.3)
26
33
  openapi_parser (~> 1.0)
27
34
  rack (>= 1.5)
28
35
  concurrent-ruby (1.2.2)
29
- dry-core (1.0.0)
36
+ connection_pool (2.4.1)
37
+ drb (2.2.0)
38
+ ruby2_keywords
39
+ dry-core (1.0.1)
30
40
  concurrent-ruby (~> 1.0)
31
41
  zeitwerk (~> 2.6)
32
42
  dry-inflector (1.0.0)
@@ -40,12 +50,12 @@ GEM
40
50
  dry-inflector (~> 1.0)
41
51
  dry-logic (~> 1.4)
42
52
  zeitwerk (~> 2.6)
43
- grape (1.7.1)
44
- activesupport
53
+ grape (2.0.0)
54
+ activesupport (>= 5)
45
55
  builder
46
56
  dry-types (>= 1.1)
47
57
  mustermann-grape (~> 1.0.0)
48
- rack (>= 1.3.0, < 3)
58
+ rack (>= 1.3.0)
49
59
  rack-accept
50
60
  hana (1.3.7)
51
61
  hanami-api (0.3.0)
@@ -74,40 +84,41 @@ GEM
74
84
  mustermann (= 3.0.0)
75
85
  mustermann-grape (1.0.2)
76
86
  mustermann (>= 1.0.0)
87
+ mutex_m (0.2.0)
77
88
  nio4r (2.5.9)
78
- openapi_parameters (0.2.2)
89
+ openapi_parameters (0.3.1)
79
90
  rack (>= 2.2)
80
91
  zeitwerk (~> 2.6)
81
92
  openapi_parser (1.0.0)
82
- puma (6.3.1)
93
+ puma (6.4.0)
83
94
  nio4r (~> 2.0)
84
95
  rack (2.2.8)
85
96
  rack-accept (0.4.5)
86
97
  rack (>= 0.4)
87
- rack-protection (3.0.6)
88
- rack
89
- regexp_parser (2.8.1)
90
- roda (3.68.0)
98
+ rack-protection (3.1.0)
99
+ rack (~> 2.2, >= 2.2.4)
100
+ regexp_parser (2.8.2)
101
+ roda (3.73.0)
91
102
  rack
92
103
  ruby2_keywords (0.0.5)
93
104
  seg (1.2.0)
94
105
  simpleidn (0.2.1)
95
106
  unf (~> 0.1.4)
96
- sinatra (3.0.6)
107
+ sinatra (3.1.0)
97
108
  mustermann (~> 3.0)
98
109
  rack (~> 2.2, >= 2.2.4)
99
- rack-protection (= 3.0.6)
110
+ rack-protection (= 3.1.0)
100
111
  tilt (~> 2.0)
101
112
  syro (3.2.1)
102
113
  rack (>= 1.6.0)
103
114
  seg
104
- tilt (2.1.0)
115
+ tilt (2.3.0)
105
116
  tzinfo (2.0.6)
106
117
  concurrent-ruby (~> 1.0)
107
118
  unf (0.1.4)
108
119
  unf_ext
109
- unf_ext (0.0.8.2)
110
- zeitwerk (2.6.11)
120
+ unf_ext (0.0.9)
121
+ zeitwerk (2.6.12)
111
122
 
112
123
  PLATFORMS
113
124
  arm64-darwin-21
@@ -4,29 +4,16 @@ require 'multi_json'
4
4
 
5
5
  module OpenapiFirst
6
6
  class BodyParserMiddleware
7
- def initialize(app, options = {})
7
+ def initialize(app)
8
8
  @app = app
9
- @raise = options.fetch(:raise_error, false)
10
9
  end
11
10
 
12
- RACK_INPUT = 'rack.input'
13
11
  ROUTER_PARSED_BODY = 'router.parsed_body'
12
+ private_constant :ROUTER_PARSED_BODY
14
13
 
15
14
  def call(env)
16
15
  env[ROUTER_PARSED_BODY] = parse_body(env)
17
16
  @app.call(env)
18
- rescue BodyParsingError => e
19
- raise if @raise
20
-
21
- err = { title: "Failed to parse body as #{env['CONTENT_TYPE']}", status: '400' }
22
- err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
23
- errors = [err]
24
-
25
- Rack::Response.new(
26
- MultiJson.dump(errors:),
27
- 400,
28
- Rack::CONTENT_TYPE => 'application/vnd.api+json'
29
- ).finish
30
17
  end
31
18
 
32
19
  private
@@ -40,8 +27,8 @@ module OpenapiFirst
40
27
  return request.POST if request.form_data?
41
28
 
42
29
  body
43
- rescue MultiJson::ParseError => e
44
- raise BodyParsingError, e
30
+ rescue MultiJson::ParseError
31
+ raise BodyParsingError, 'Failed to parse body as application/json'
45
32
  end
46
33
 
47
34
  def read_body(request)
@@ -2,11 +2,12 @@
2
2
 
3
3
  module OpenapiFirst
4
4
  class Config
5
- def initialize(error_response: :default)
6
- @error_response = error_response
5
+ def initialize(error_response: :default, request_validation_raise_error: false)
6
+ @error_response = Plugins.find_error_response(error_response)
7
+ @request_validation_raise_error = request_validation_raise_error
7
8
  end
8
9
 
9
- attr_reader :error_response
10
+ attr_reader :error_response, :request_validation_raise_error
10
11
 
11
12
  def self.default_options
12
13
  @default_options ||= new
@@ -3,20 +3,34 @@
3
3
  module OpenapiFirst
4
4
  # This is the base class for error responses
5
5
  class ErrorResponse
6
- ## @param status [Integer] The HTTP status code.
7
- ## @param title [String] The title of the error. Usually the name of the HTTP status code.
8
- ## @param location [Symbol] The location of the error (:request_body, :query, :header, :cookie, :path).
9
- ## @param validation_result [ValidationResult]
10
- def initialize(status:, location:, title:, validation_result:)
11
- @status = status
12
- @title = title
13
- @location = location
14
- @validation_output = validation_result&.output
15
- @schema = validation_result&.schema
16
- @data = validation_result&.data
6
+ ## @param request [Hash] The Rack request env
7
+ ## @param request_validation_error [OpenapiFirst::RequestValidationError]
8
+ def initialize(env, request_validation_error)
9
+ @env = env
10
+ @request_validation_error = request_validation_error
17
11
  end
18
12
 
19
- attr_reader :status, :location, :title, :schema, :data, :validation_output
13
+ extend Forwardable
14
+
15
+ attr_reader :env, :request_validation_error
16
+
17
+ def_delegators :@request_validation_error, :status, :location, :schema_validation
18
+
19
+ def validation_output
20
+ schema_validation&.output
21
+ end
22
+
23
+ def schema
24
+ schema_validation&.schema
25
+ end
26
+
27
+ def data
28
+ schema_validation&.data
29
+ end
30
+
31
+ def message
32
+ request_validation_error.message
33
+ end
20
34
 
21
35
  def render
22
36
  Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponses
5
+ class Default < ErrorResponse
6
+ OpenapiFirst::Plugins.register_error_response(:default, self)
7
+
8
+ def body
9
+ MultiJson.dump({ errors: serialized_errors })
10
+ end
11
+
12
+ def content_type
13
+ 'application/json'
14
+ end
15
+
16
+ def serialized_errors
17
+ return default_errors unless validation_output
18
+
19
+ key = pointer_key
20
+ validation_errors&.map do |error|
21
+ {
22
+ status: status.to_s,
23
+ source: { key => pointer(error['instanceLocation']) },
24
+ title: error['error']
25
+ }
26
+ end
27
+ end
28
+
29
+ def validation_errors
30
+ validation_output['errors'] || [validation_output]
31
+ end
32
+
33
+ def default_errors
34
+ [{
35
+ status: status.to_s,
36
+ title: message
37
+ }]
38
+ end
39
+
40
+ def pointer_key
41
+ case location
42
+ when :body
43
+ :pointer
44
+ when :query, :path
45
+ :parameter
46
+ else
47
+ location
48
+ end
49
+ end
50
+
51
+ def pointer(data_pointer)
52
+ return data_pointer if location == :body
53
+
54
+ data_pointer.delete_prefix('/')
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponses
5
+ class JsonApi < ErrorResponse
6
+ OpenapiFirst::Plugins.register_error_response(:json_api, self)
7
+
8
+ def body
9
+ MultiJson.dump({ errors: serialized_errors })
10
+ end
11
+
12
+ def content_type
13
+ 'application/vnd.api+json'
14
+ end
15
+
16
+ def serialized_errors
17
+ return default_errors unless validation_output
18
+
19
+ key = pointer_key
20
+ validation_errors&.map do |error|
21
+ {
22
+ status: status.to_s,
23
+ source: { key => pointer(error['instanceLocation']) },
24
+ title: error['error']
25
+ }
26
+ end
27
+ end
28
+
29
+ def validation_errors
30
+ validation_output['errors'] || [validation_output]
31
+ end
32
+
33
+ def default_errors
34
+ [{
35
+ status: status.to_s,
36
+ title: message
37
+ }]
38
+ end
39
+
40
+ def pointer_key
41
+ case location
42
+ when :body
43
+ :pointer
44
+ when :query, :path
45
+ :parameter
46
+ else
47
+ location
48
+ end
49
+ end
50
+
51
+ def pointer(data_pointer)
52
+ return data_pointer if location == :body
53
+
54
+ data_pointer.delete_prefix('/')
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class JsonSchema
5
+ Result = Struct.new(:output, :schema, :data, keyword_init: true) do
6
+ def valid? = output['valid']
7
+ def error? = !output['valid']
8
+
9
+ # Returns a message that is used in exception messages.
10
+ def message
11
+ return if valid?
12
+
13
+ (output['errors']&.map { |e| e['error'] }&.join('. ') || output['error'])
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json_schemer'
4
- require_relative 'validation_result'
4
+ require_relative 'json_schema/result'
5
5
 
6
6
  module OpenapiFirst
7
- class SchemaValidation
8
- attr_reader :raw_schema
7
+ class JsonSchema
8
+ attr_reader :schema
9
9
 
10
10
  SCHEMAS = {
11
11
  '3.1' => 'https://spec.openapis.org/oas/3.1/dialect/base',
@@ -13,7 +13,7 @@ module OpenapiFirst
13
13
  }.freeze
14
14
 
15
15
  def initialize(schema, openapi_version:, write: true)
16
- @raw_schema = schema
16
+ @schema = schema
17
17
  @schemer = JSONSchemer.schema(
18
18
  schema,
19
19
  access_mode: write ? 'write' : 'read',
@@ -25,9 +25,9 @@ module OpenapiFirst
25
25
  end
26
26
 
27
27
  def validate(data)
28
- ValidationResult.new(
28
+ Result.new(
29
29
  output: @schemer.validate(data),
30
- schema: raw_schema,
30
+ schema:,
31
31
  data:
32
32
  )
33
33
  end
@@ -2,8 +2,7 @@
2
2
 
3
3
  require 'forwardable'
4
4
  require 'set'
5
- require_relative 'schema_validation'
6
- require_relative 'operation_schemas'
5
+ require_relative 'json_schema'
7
6
 
8
7
  module OpenapiFirst
9
8
  class Operation # rubocop:disable Metrics/ClassLength
@@ -55,7 +54,7 @@ module OpenapiFirst
55
54
  schema = media_type['schema']
56
55
  return unless schema
57
56
 
58
- SchemaValidation.new(schema, write: false, openapi_version:)
57
+ JsonSchema.new(schema, write: false, openapi_version:)
59
58
  end
60
59
 
61
60
  def request_body_schema(request_content_type)
@@ -63,7 +62,7 @@ module OpenapiFirst
63
62
  content = operation_object.dig('requestBody', 'content')
64
63
  media_type = find_content_for_content_type(content, request_content_type)
65
64
  schema = media_type&.fetch('schema', nil)
66
- SchemaValidation.new(schema, write: write?, openapi_version:) if schema
65
+ JsonSchema.new(schema, write: write?, openapi_version:) if schema
67
66
  end
68
67
  end
69
68
 
@@ -114,13 +113,42 @@ module OpenapiFirst
114
113
  end
115
114
  end
116
115
 
117
- # visibility: private
118
- def schemas
119
- @schemas ||= OperationSchemas.new(self)
116
+ # Return JSON Schema of for all query parameters
117
+ def query_parameters_schema
118
+ @query_parameters_schema ||= build_json_schema(query_parameters)
119
+ end
120
+
121
+ # Return JSON Schema of for all path parameters
122
+ def path_parameters_schema
123
+ @path_parameters_schema ||= build_json_schema(path_parameters)
124
+ end
125
+
126
+ def header_parameters_schema
127
+ @header_parameters_schema ||= build_json_schema(header_parameters)
128
+ end
129
+
130
+ def cookie_parameters_schema
131
+ @cookie_parameters_schema ||= build_json_schema(cookie_parameters)
120
132
  end
121
133
 
122
134
  private
123
135
 
136
+ # Build JSON Schema for given parameter definitions
137
+ # @parameter_defs [Array<Hash>] Parameter definitions
138
+ def build_json_schema(parameter_defs)
139
+ init_schema = {
140
+ 'type' => 'object',
141
+ 'properties' => {},
142
+ 'required' => []
143
+ }
144
+ schema = parameter_defs.each_with_object(init_schema) do |parameter_def, result|
145
+ parameter = OpenapiParameters::Parameter.new(parameter_def)
146
+ result['properties'][parameter.name] = parameter.schema if parameter.schema
147
+ result['required'] << parameter.name if parameter.required?
148
+ end
149
+ JsonSchema.new(schema, openapi_version:)
150
+ end
151
+
124
152
  def response_by_code(status)
125
153
  operation_object.dig('responses', status.to_s) ||
126
154
  operation_object.dig('responses', "#{status / 100}XX") ||
@@ -17,7 +17,7 @@ module OpenapiFirst
17
17
  private
18
18
 
19
19
  def validate_request_content_type!(operation, content_type)
20
- operation.valid_request_content_type?(content_type) || OpenapiFirst.error!(415)
20
+ operation.valid_request_content_type?(content_type) || RequestValidation.fail!(415, :header)
21
21
  end
22
22
 
23
23
  def validate_request_body!(operation, body, content_type)
@@ -27,15 +27,15 @@ module OpenapiFirst
27
27
  schema = operation&.request_body_schema(content_type)
28
28
  return unless schema
29
29
 
30
- validation_result = schema.validate(body)
31
- OpenapiFirst.error!(400, :request_body, validation_result:) if validation_result.error?
30
+ schema_validation = schema.validate(body)
31
+ RequestValidation.fail!(400, :body, schema_validation:) if schema_validation.error?
32
32
  body
33
33
  end
34
34
 
35
35
  def validate_request_body_presence!(body, operation)
36
36
  return unless operation.request_body['required'] && body.nil?
37
37
 
38
- OpenapiFirst.error!(400, :request_body, title: 'Request body is required')
38
+ RequestValidation.fail!(400, :body)
39
39
  end
40
40
  end
41
41
  end
@@ -6,15 +6,37 @@ require_relative 'use_router'
6
6
  require_relative 'error_response'
7
7
  require_relative 'request_body_validator'
8
8
  require_relative 'string_keyed_hash'
9
+ require_relative 'request_validation_error'
9
10
  require 'openapi_parameters'
10
11
 
11
12
  module OpenapiFirst
13
+ # A Rack middleware to validate requests against an OpenAPI API description
12
14
  class RequestValidation
13
15
  prepend UseRouter
14
16
 
17
+ FAIL = :request_validation_failed
18
+ private_constant :FAIL
19
+
20
+ # @param status [Integer] The intended HTTP status code (usually 400)
21
+ # @param location [Symbol] One of :body, :header, :cookie, :query, :path
22
+ # @param schema_validation [OpenapiFirst::JsonSchema::Result]
23
+ def self.fail!(status, location, schema_validation: nil)
24
+ throw FAIL, RequestValidationError.new(
25
+ status:,
26
+ location:,
27
+ schema_validation:
28
+ )
29
+ end
30
+
31
+ # @param app The parent Rack application
32
+ # @param options An optional Hash of configuration options to override defaults
33
+ # :error_response A Boolean indicating whether to raise an error if validation fails.
34
+ # default: OpenapiFirst::ErrorResponses::Default (Config.default_options.error_response)
35
+ # :raise_error The Class to use for error responses.
36
+ # default: false (Config.default_options.request_validation_raise_error)
15
37
  def initialize(app, options = {})
16
38
  @app = app
17
- @raise = options.fetch(:raise_error, false)
39
+ @raise = options.fetch(:raise_error, Config.default_options.request_validation_raise_error)
18
40
  @error_response_class =
19
41
  Plugins.find_error_response(options.fetch(:error_response, Config.default_options.error_response))
20
42
  end
@@ -25,43 +47,23 @@ module OpenapiFirst
25
47
 
26
48
  error = validate_request(operation, env)
27
49
  if error
28
- location, title = error.values_at(:location, :title)
29
- raise RequestInvalidError, error_message(title, location) if @raise
50
+ raise RequestInvalidError, error.error_message if @raise
30
51
 
31
- return error_response(error).render
52
+ return @error_response_class.new(env, error).render
32
53
  end
33
54
  @app.call(env)
34
55
  end
35
56
 
36
57
  private
37
58
 
38
- def error_message(title, location)
39
- return title unless location
40
-
41
- "#{TOPICS.fetch(location)} #{title}"
42
- end
43
-
44
- TOPICS = {
45
- request_body: 'Request body invalid:',
46
- query: 'Query parameter invalid:',
47
- header: 'Header parameter invalid:',
48
- path: 'Path segment invalid:',
49
- cookie: 'Cookie value invalid:'
50
- }.freeze
51
- private_constant :TOPICS
52
-
53
- def error_response(error_object)
54
- @error_response_class.new(**error_object)
55
- end
56
-
57
59
  def validate_request(operation, env)
58
- catch(:error) do
60
+ catch(FAIL) do
59
61
  env[PARAMS] = {}
60
62
  validate_query_params!(operation, env)
61
63
  validate_path_params!(operation, env)
62
64
  validate_cookie_params!(operation, env)
63
65
  validate_header_params!(operation, env)
64
- RequestBodyValidator.new(operation, env).validate! if operation.request_body
66
+ validate_request_body!(operation, env)
65
67
  nil
66
68
  end
67
69
  end
@@ -72,8 +74,8 @@ module OpenapiFirst
72
74
 
73
75
  hashy = StringKeyedHash.new(env[Router::RAW_PATH_PARAMS])
74
76
  unpacked_path_params = OpenapiParameters::Path.new(path_parameters).unpack(hashy)
75
- validation_result = operation.schemas.path_parameters_schema.validate(unpacked_path_params)
76
- OpenapiFirst.error!(400, :path, validation_result:) if validation_result.error?
77
+ schema_validation = operation.path_parameters_schema.validate(unpacked_path_params)
78
+ RequestValidation.fail!(400, :path, schema_validation:) if schema_validation.error?
77
79
  env[PATH_PARAMS] = unpacked_path_params
78
80
  env[PARAMS].merge!(unpacked_path_params)
79
81
  end
@@ -83,8 +85,8 @@ module OpenapiFirst
83
85
  return if operation.query_parameters.empty?
84
86
 
85
87
  unpacked_query_params = OpenapiParameters::Query.new(query_parameters).unpack(env['QUERY_STRING'])
86
- validation_result = operation.schemas.query_parameters_schema.validate(unpacked_query_params)
87
- OpenapiFirst.error!(400, :query, validation_result:) if validation_result.error?
88
+ schema_validation = operation.query_parameters_schema.validate(unpacked_query_params)
89
+ RequestValidation.fail!(400, :query, schema_validation:) if schema_validation.error?
88
90
  env[QUERY_PARAMS] = unpacked_query_params
89
91
  env[PARAMS].merge!(unpacked_query_params)
90
92
  end
@@ -94,8 +96,8 @@ module OpenapiFirst
94
96
  return unless cookie_parameters&.any?
95
97
 
96
98
  unpacked_params = OpenapiParameters::Cookie.new(cookie_parameters).unpack(env['HTTP_COOKIE'])
97
- validation_result = operation.schemas.cookie_parameters_schema.validate(unpacked_params)
98
- OpenapiFirst.error!(400, :cookie, validation_result:) if validation_result.error?
99
+ schema_validation = operation.cookie_parameters_schema.validate(unpacked_params)
100
+ RequestValidation.fail!(400, :cookie, schema_validation:) if schema_validation.error?
99
101
  env[COOKIE_PARAMS] = unpacked_params
100
102
  end
101
103
 
@@ -104,9 +106,13 @@ module OpenapiFirst
104
106
  return if header_parameters.empty?
105
107
 
106
108
  unpacked_header_params = OpenapiParameters::Header.new(header_parameters).unpack_env(env)
107
- validation_result = operation.schemas.header_parameters_schema.validate(unpacked_header_params)
108
- OpenapiFirst.error!(400, :header, validation_result:) if validation_result.error?
109
+ schema_validation = operation.header_parameters_schema.validate(unpacked_header_params)
110
+ RequestValidation.fail!(400, :header, schema_validation:) if schema_validation.error?
109
111
  env[HEADER_PARAMS] = unpacked_header_params
110
112
  end
113
+
114
+ def validate_request_body!(operation, env)
115
+ RequestBodyValidator.new(operation, env).validate! if operation.request_body
116
+ end
111
117
  end
112
118
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class RequestValidationError
5
+ def initialize(status:, location:, message: nil, schema_validation: nil)
6
+ @status = status
7
+ @location = location
8
+ @message = message
9
+ @schema_validation = schema_validation
10
+ end
11
+
12
+ attr_reader :status, :request, :location, :schema_validation
13
+
14
+ def message
15
+ @message || schema_validation&.message || Rack::Utils::HTTP_STATUS_CODES[status]
16
+ end
17
+
18
+ def error_message
19
+ "#{TOPICS.fetch(location)} #{message}"
20
+ end
21
+
22
+ TOPICS = {
23
+ body: 'Request body invalid:',
24
+ query: 'Query parameter invalid:',
25
+ header: 'Header parameter invalid:',
26
+ path: 'Path segment invalid:',
27
+ cookie: 'Cookie value invalid:'
28
+ }.freeze
29
+ private_constant :TOPICS
30
+ end
31
+ end
@@ -65,10 +65,10 @@ module OpenapiFirst
65
65
 
66
66
  return unless definition.key?('schema')
67
67
 
68
- validation = SchemaValidation.new(definition['schema'], openapi_version:)
68
+ validation = JsonSchema.new(definition['schema'], openapi_version:)
69
69
  value = unpacked_headers[name]
70
- validation_result = validation.validate(value)
71
- raise ResponseHeaderInvalidError, validation_result.message if validation_result.error?
70
+ schema_validation = validation.validate(value)
71
+ raise ResponseHeaderInvalidError, schema_validation.message if schema_validation.error?
72
72
  end
73
73
 
74
74
  def unpack_response_headers(response_header_definitions, response_headers)
@@ -4,6 +4,7 @@ require_relative 'response_validation'
4
4
  require_relative 'router'
5
5
 
6
6
  module OpenapiFirst
7
+ # A class to run manual response validation
7
8
  class ResponseValidator
8
9
  def initialize(spec)
9
10
  @spec = spec
@@ -17,6 +17,7 @@ module OpenapiFirst
17
17
  @app = app
18
18
  @raise = options.fetch(:raise_error, false)
19
19
  @not_found = options.fetch(:not_found, :halt)
20
+ @error_response_class = options.fetch(:error_response, Config.default_options.error_response)
20
21
  spec = options.fetch(:spec)
21
22
  raise "You have to pass spec: when initializing #{self.class}" unless spec
22
23
 
@@ -61,21 +62,13 @@ module OpenapiFirst
61
62
  env[Rack::PATH_INFO] = Rack::Request.new(env).path
62
63
  @router.call(env)
63
64
  rescue BodyParsingError => e
64
- handle_body_parsing_error(e)
65
- ensure
66
- env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
67
- end
68
-
69
- def handle_body_parsing_error(_exception)
70
- message = 'Failed to parse body as application/json'
65
+ message = e.message
71
66
  raise RequestInvalidError, message if @raise
72
67
 
73
- error = {
74
- status: 400,
75
- title: message
76
- }
77
-
78
- ErrorResponse::Default.new(**error).finish
68
+ error = RequestValidationError.new(status: 400, location: :body, message:)
69
+ @error_response_class.new(env, error).render
70
+ ensure
71
+ env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
79
72
  end
80
73
 
81
74
  def build_router(operations)
@@ -89,9 +82,8 @@ module OpenapiFirst
89
82
  )
90
83
  end
91
84
  end
92
- raise_error = @raise
93
85
  Rack::Builder.app do
94
- use(BodyParserMiddleware, raise_error:)
86
+ use(BodyParserMiddleware)
95
87
  run router
96
88
  end
97
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '1.0.0.beta4'
4
+ VERSION = '1.0.0.beta5'
5
5
  end
data/lib/openapi_first.rb CHANGED
@@ -11,7 +11,8 @@ require_relative 'openapi_first/router'
11
11
  require_relative 'openapi_first/request_validation'
12
12
  require_relative 'openapi_first/response_validator'
13
13
  require_relative 'openapi_first/response_validation'
14
- require_relative 'openapi_first/default_error_response'
14
+ require_relative 'openapi_first/error_responses/default'
15
+ require_relative 'openapi_first/error_responses/json_api'
15
16
 
16
17
  module OpenapiFirst
17
18
  # The OpenAPI operation for the current request
@@ -35,18 +36,6 @@ module OpenapiFirst
35
36
  # The parsed request body
36
37
  REQUEST_BODY = 'openapi.parsed_request_body'
37
38
 
38
- class << self
39
- # Throws an error in the middle of the request validation to stop validation and send a response.
40
- def error!(status, location = nil, title: nil, validation_result: nil)
41
- throw :error, {
42
- status:,
43
- location:,
44
- title: title || validation_result&.output&.fetch('error') || Rack::Utils::HTTP_STATUS_CODES[status],
45
- validation_result:
46
- }
47
- end
48
- end
49
-
50
39
  def self.load(spec_path, only: nil)
51
40
  resolved = Dir.chdir(File.dirname(spec_path)) do
52
41
  content = YAML.load_file(File.basename(spec_path))
@@ -37,8 +37,8 @@ Gem::Specification.new do |spec|
37
37
  spec.add_runtime_dependency 'hanami-router', '~> 2.0.0'
38
38
  spec.add_runtime_dependency 'json_refs', '~> 0.1', '>= 0.1.7'
39
39
  spec.add_runtime_dependency 'json_schemer', '~> 2.0.0'
40
- spec.add_runtime_dependency 'multi_json', '~> 1.14'
41
- spec.add_runtime_dependency 'openapi_parameters', '~> 0.2.2'
40
+ spec.add_runtime_dependency 'multi_json', '~> 1.15'
41
+ spec.add_runtime_dependency 'openapi_parameters', '>= 0.3.1', '< 2.0'
42
42
  spec.add_runtime_dependency 'rack', '>= 2.2', '< 4.0'
43
43
  spec.metadata = {
44
44
  'rubygems_mfa_required' => 'true'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.beta4
4
+ version: 1.0.0.beta5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-10-25 00:00:00.000000000 Z
11
+ date: 2023-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: hanami-router
@@ -64,28 +64,34 @@ dependencies:
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '1.14'
67
+ version: '1.15'
68
68
  type: :runtime
69
69
  prerelease: false
70
70
  version_requirements: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '1.14'
74
+ version: '1.15'
75
75
  - !ruby/object:Gem::Dependency
76
76
  name: openapi_parameters
77
77
  requirement: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - "~>"
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 0.3.1
82
+ - - "<"
80
83
  - !ruby/object:Gem::Version
81
- version: 0.2.2
84
+ version: '2.0'
82
85
  type: :runtime
83
86
  prerelease: false
84
87
  version_requirements: !ruby/object:Gem::Requirement
85
88
  requirements:
86
- - - "~>"
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 0.3.1
92
+ - - "<"
87
93
  - !ruby/object:Gem::Version
88
- version: 0.2.2
94
+ version: '2.0'
89
95
  - !ruby/object:Gem::Dependency
90
96
  name: rack
91
97
  requirement: !ruby/object:Gem::Requirement
@@ -153,22 +159,23 @@ files:
153
159
  - lib/openapi_first.rb
154
160
  - lib/openapi_first/body_parser_middleware.rb
155
161
  - lib/openapi_first/config.rb
156
- - lib/openapi_first/default_error_response.rb
157
162
  - lib/openapi_first/definition.rb
158
163
  - lib/openapi_first/error_response.rb
164
+ - lib/openapi_first/error_responses/default.rb
165
+ - lib/openapi_first/error_responses/json_api.rb
159
166
  - lib/openapi_first/errors.rb
167
+ - lib/openapi_first/json_schema.rb
168
+ - lib/openapi_first/json_schema/result.rb
160
169
  - lib/openapi_first/operation.rb
161
- - lib/openapi_first/operation_schemas.rb
162
170
  - lib/openapi_first/plugins.rb
163
171
  - lib/openapi_first/request_body_validator.rb
164
172
  - lib/openapi_first/request_validation.rb
173
+ - lib/openapi_first/request_validation_error.rb
165
174
  - lib/openapi_first/response_validation.rb
166
175
  - lib/openapi_first/response_validator.rb
167
176
  - lib/openapi_first/router.rb
168
- - lib/openapi_first/schema_validation.rb
169
177
  - lib/openapi_first/string_keyed_hash.rb
170
178
  - lib/openapi_first/use_router.rb
171
- - lib/openapi_first/validation_result.rb
172
179
  - lib/openapi_first/version.rb
173
180
  - openapi_first.gemspec
174
181
  homepage: https://github.com/ahx/openapi_first
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OpenapiFirst
4
- class DefaultErrorResponse < ErrorResponse
5
- OpenapiFirst::Plugins.register_error_response(:default, self)
6
-
7
- def body
8
- MultiJson.dump({ errors: serialized_errors })
9
- end
10
-
11
- def serialized_errors
12
- return default_errors unless validation_output
13
-
14
- key = pointer_key
15
- [
16
- {
17
- source: { key => pointer(validation_output['instanceLocation']) },
18
- title: validation_output['error']
19
- }
20
- ]
21
- end
22
-
23
- def default_errors
24
- [{
25
- status: status.to_s,
26
- title:
27
- }]
28
- end
29
-
30
- def pointer_key
31
- case location
32
- when :request_body
33
- :pointer
34
- when :query, :path
35
- :parameter
36
- else
37
- location
38
- end
39
- end
40
-
41
- def pointer(data_pointer)
42
- return data_pointer if location == :request_body
43
-
44
- data_pointer.delete_prefix('/')
45
- end
46
- end
47
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openapi_parameters/parameter'
4
- require_relative 'schema_validation'
5
-
6
- module OpenapiFirst
7
- # This class is basically a cache for JSON Schemas of parameters
8
- class OperationSchemas
9
- # @operation [OpenapiFirst::Operation]
10
- def initialize(operation)
11
- @operation = operation
12
- end
13
-
14
- attr_reader :operation
15
-
16
- # Return JSON Schema of for all query parameters
17
- def query_parameters_schema
18
- @query_parameters_schema ||= build_json_schema(operation.query_parameters)
19
- end
20
-
21
- # Return JSON Schema of for all path parameters
22
- def path_parameters_schema
23
- @path_parameters_schema ||= build_json_schema(operation.path_parameters)
24
- end
25
-
26
- def header_parameters_schema
27
- @header_parameters_schema ||= build_json_schema(operation.header_parameters)
28
- end
29
-
30
- def cookie_parameters_schema
31
- @cookie_parameters_schema ||= build_json_schema(operation.cookie_parameters)
32
- end
33
-
34
- private
35
-
36
- # Build JSON Schema for given parameter definitions
37
- # @parameter_defs [Array<Hash>] Parameter definitions
38
- def build_json_schema(parameter_defs)
39
- init_schema = {
40
- 'type' => 'object',
41
- 'properties' => {},
42
- 'required' => []
43
- }
44
- schema = parameter_defs.each_with_object(init_schema) do |parameter_def, result|
45
- parameter = OpenapiParameters::Parameter.new(parameter_def)
46
- result['properties'][parameter.name] = parameter.schema if parameter.schema
47
- result['required'] << parameter.name if parameter.required?
48
- end
49
- SchemaValidation.new(schema, openapi_version: operation.openapi_version)
50
- end
51
- end
52
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OpenapiFirst
4
- ValidationResult = Struct.new(:output, :schema, :data, keyword_init: true) do
5
- def valid? = output['valid']
6
- def error? = !output['valid']
7
-
8
- # Returns a message that is used in exception messages.
9
- def message
10
- return if valid?
11
-
12
- (output['errors']&.map { |e| e['error'] }&.join('. ') || output['error'])&.concat('.')
13
- end
14
- end
15
- end