diff --git a/README.md b/README.md index 0b7cee82..7a0bef82 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ You can use openapi_first on production for [request validation](#request-valida - [Configuration](#configuration) - [Hooks](#hooks) - [Alternatives](#alternatives) +- [Frequently Asked Questions](#frequently-asked-questions) - [Development](#development) - [Benchmarks](#benchmarks) - [Contributing](#contributing) @@ -327,6 +328,74 @@ That aside, closer integration with specific frameworks like Sinatra, Hanami, Ro This gem was inspired by [committee](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python). Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a). +## Frequently Asked Questions + +### How can I adapt request paths that don't match my schema? + +If your API is deployed at a different path than what's defined in your OpenAPI schema, you can use `env[OpenapiFirst::PATH]` to override the path used for schema matching. + +Let's say you have `openapi.yaml` like this: + +```yaml +servers: + - url: https://yourhost/api +paths: + # The actual endpoint URL is https://yourhost/api/resource + /resource: +``` + +Here your OpenAPI schema defines endpoints starting with `/resource` but your actual application is mounted at `/api/resource`. You can bridge the gap by transforming the path via the `path:` configuration: + +```ruby +oad = OpenapiFirst.load('openapi.yaml') do |config| + config.path = ->(req) { request.path.delete_prefix('/api') } +end +# Add your custom middleware +use OpenapiFirst::Middlewares::RequestValidation, oad + +# You can add ResponseValidation without any customization. +use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml' +``` + +In this case, you might want to serve APIs on `/api` while serving rendered pages on other paths which are not managed by OpenAPI schema in a single application. + +You can add some lines to selectively validate only paths under `/api` while bypassing others: + +```diff + env[OpenapiFirst::PATH] = request.path.to_s.sub(%r"^/api", "") + ++ # Only validate paths under /api/ ++ if request.path.start_with?('/api/') + super ++ else ++ @app.call(env) ++ end + end +``` + +And the final code is: + +```ruby +class CustomOpenAPIValidation < OpenapiFirst::Middlewares::RequestValidation + def call(env) + request = Rack::Request.new(env) + + # Strip the "/api" prefix for schema matching + env[OpenapiFirst::PATH] = request.path.to_s.sub(%r"^/api", "") + + # Only validate paths under /api/ + if request.path.start_with?('/api/') + super + else + @app.call(env) + end + end +end + +use CustomOpenAPIValidation, 'openapi.yaml' +use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml' +``` + ## Development Run `bin/setup` to install dependencies. diff --git a/lib/openapi_first/configuration.rb b/lib/openapi_first/configuration.rb index 2a5687dc..882e9967 100644 --- a/lib/openapi_first/configuration.rb +++ b/lib/openapi_first/configuration.rb @@ -15,10 +15,11 @@ def initialize @request_validation_raise_error = false @response_validation_raise_error = true @hooks = (HOOKS.map { [_1, Set.new] }).to_h + @path = nil end attr_reader :request_validation_error_response, :hooks - attr_accessor :request_validation_raise_error, :response_validation_raise_error + attr_accessor :request_validation_raise_error, :response_validation_raise_error, :path def clone copy = super diff --git a/lib/openapi_first/definition.rb b/lib/openapi_first/definition.rb index cf8bd530..e6e8cf4c 100644 --- a/lib/openapi_first/definition.rb +++ b/lib/openapi_first/definition.rb @@ -99,7 +99,9 @@ def validate_response(rack_request, rack_response, raise_error: false) private def resolve_path(rack_request) - rack_request.env[PATH] || rack_request.path + return rack_request.path unless @config.path + + @config.path.call(rack_request) end end end diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 3bfd824a..0cefc5bb 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -70,26 +70,30 @@ def build_request(path, method: 'GET') end describe '#validate_request' do + let(:definition_contents) do + { + 'openapi' => '3.1.0', + 'paths' => { + '/stuff/{id}' => { + 'get' => { + 'parameters' => [ + { + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => { + 'type' => 'integer' + } + } + ] + } + } + } + } + end + let(:definition) do - OpenapiFirst.parse({ - 'openapi' => '3.1.0', - 'paths' => { - '/stuff/{id}' => { - 'get' => { - 'parameters' => [ - { - 'name' => 'id', - 'in' => 'path', - 'required' => true, - 'schema' => { - 'type' => 'integer' - } - } - ] - } - } - } - }) + OpenapiFirst.parse(definition_contents) end context 'when request is valid' do @@ -256,13 +260,14 @@ def build_request(path, method: 'GET') end context 'with an alternate path used for schema matching' do - let(:request) do - build_request('/prefix/stuff/42').tap do |req| - req.env[OpenapiFirst::PATH] = '/stuff/42' + let(:definition) do + OpenapiFirst.parse(definition_contents) do |config| + config.path = ->(req) { req.path.delete_prefix('/prefix') } end end it 'returns a valid request' do + request = build_request('/prefix/stuff/42') validated = definition.validate_request(request) expect(validated).to be_valid expect(validated.parsed_path_parameters).to eq({ 'id' => 42 }) @@ -271,33 +276,37 @@ def build_request(path, method: 'GET') end describe '#validate_response' do + let(:definition_contents) do + { + 'openapi' => '3.1.0', + 'paths' => { + '/stuff' => { + 'get' => { + 'responses' => { + '200' => { + 'description' => 'OK', + 'content' => { + 'application/json' => { + 'schema' => { + 'type' => 'object', + 'properties' => { + 'id' => { + 'type' => 'integer' + } + } + } + } + } + } + } + } + } + } + } + end + let(:definition) do - OpenapiFirst.parse({ - 'openapi' => '3.1.0', - 'paths' => { - '/stuff' => { - 'get' => { - 'responses' => { - '200' => { - 'description' => 'OK', - 'content' => { - 'application/json' => { - 'schema' => { - 'type' => 'object', - 'properties' => { - 'id' => { - 'type' => 'integer' - } - } - } - } - } - } - } - } - } - } - }) + OpenapiFirst.parse(definition_contents) end let(:request) { build_request('/stuff') } @@ -353,11 +362,12 @@ def build_request(path, method: 'GET') end context 'with an alternate path used for schema matching' do - let(:request) do - build_request('/prefix/stuff/42').tap do |req| - req.env[OpenapiFirst::PATH] = '/stuff' + let(:definition) do + OpenapiFirst.parse(definition_contents) do |config| + config.path = ->(req) { req.path.delete_prefix('/prefix') } end end + let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) } it 'returns a valid response' do