openapi_first 1.0.0.beta4 → 1.0.0.beta5

Sign up to get free protection for your applications and to get access to all the features.
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