openapi_first is a Ruby gem for request / response validation and contract-testing against an OpenAPI 3.0 or 3.1 Openapi API description (OAD). It makes an APIFirst workflow easy and reliable.
Configure
OpenapiFirst.register('openapi/openapi.yaml')Use an OAD to validate incoming requests:
use OpenapiFirst::Middlewares::RequestValidationTurn your request tests into contract tests against an OAD:
# spec_helper.rb
require 'openapi_first'
OpenapiFirst::Test.setup
require 'my_app'
RSpec.configure do |config|
config.include OpenapiFirst::Test::Methods[MyApp], type: :request
end- Configuration
- Rack Middlewares
- Contract testing
- Manual use
- Framework integration
- Hooks
- Alternatives
- Frequently Asked Questions
- Development
You should register OADs globally so you don't have to load the file multiple times or to refernce them by Symbol (like :v1 in this example).
OpenapiFirst.configure do |config|
config.register('openapi/openapi.yaml') # :default
config.register('openapi/v1.openapi.yaml', as: :v1)
endYou can configure default options globally:
OpenapiFirst.configure do |config|
# Specify which plugin is used to render error responses returned by the request validation middleware (defaults to :default)
config.request_validation_error_response = :jsonapi
endor configure per instance:
OpenapiFirst.load('openapi.yaml') do |config|
config.request_validation_error_response = :jsonapi
endThe request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at env[OpenapiFirst::REQUEST] with the request parameters parsed exactly as described in your API description plus access to meta information from your API description. See Manual use for more details about that object.
use OpenapiFirst::Middlewares::RequestValidationopenapi_first produces a useful machine readable error response that can be customized. The default response looks like this. See also RFC 9457.
http-status: 400
content-type: "application/problem+json"
{
"title": "Bad Request Body",
"status": 400,
"errors": [
{
"message": "value at `/data/name` is not a string",
"pointer": "/data/name",
"code": "string"
},
{
"message": "number at `/data/numberOfLegs` is less than: 2",
"pointer": "/data/numberOfLegs",
"code": "minimum"
},
{
"message": "object at `/data` is missing required properties: mandatory",
"pointer": "/data",
"code": "required"
}
]
}openapi_first offers a JSON:API error response by passing error_response: :jsonapi:
use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', error_response: :jsonapiSee details of JSON:API error response
// http-status: 400
// content-type: "application/vnd.api+json"
{
"errors": [
{
"status": "400",
"source": {
"pointer": "/data/name"
},
"title": "value at `/data/name` is not a string",
"code": "string"
},
{
"status": "400",
"source": {
"pointer": "/data/numberOfLegs"
},
"title": "number at `/data/numberOfLegs` is less than: 2",
"code": "minimum"
},
{
"status": "400",
"source": {
"pointer": "/data"
},
"title": "object at `/data` is missing required properties: mandatory",
"code": "required"
}
]
}You can build your own custom error response with error_response: MyCustomClass that implements OpenapiFirst::ErrorResponse.
You can define custom error responses globally by including / implementing OpenapiFirst::ErrorResponse and register it via OpenapiFirst.register_error_response(my_name, MyCustomErrorResponse) and set error_response: my_name.
Uploaded files are not read during request validation. A multipart/form-data field that was sent as a file is passed through as Rack parsed it – the same shape that Sinatra and Hanami hand to your application:
file = validated_request.parsed_body['file']
file[:filename] # => "cat.jpg"
file[:type] # => "image/jpeg"
file[:tempfile] # => #<Tempfile …> Read or stream this in your application.The tempfile is only usable while the request is being handled, because Rack removes it afterwards.
This means the content of these fields is not validated, so minLength, maxLength or pattern on a field that was sent as a file are ignored. Fields that were not sent as a file are read and validated as usual, and a field with contentType: application/json in the encoding map is still parsed as JSON.
Warning
You should use Contract Testing instead of this middleware.
This middleware raises an error by default if the response is not valid.
This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
use OpenapiFirst::Middlewares::ResponseValidation if ENV['RACK_ENV'] == 'test'
# Pass `raise_error: false` to not raise an error:
use OpenapiFirst::Middlewares::ResponseValidation, raise_error: falseIf you are adopting OpenAPI you can use these options together with hooks to get notified about requests/responses that do match your API description.
You can see your OpenAPI API description as a contract that your clients can rely on as how your API behaves. There are two aspects of contract testing: Validation and Coverage. By validating requests and responses, you can avoid that your API implementation processes requests or returns responses that don't match your API description. To make sure your whole API description is implemented, openapi_first can check that all of your API description is covered when you test your API with rack-test.
Here is how to set it up:
-
Setup the test mode
# spec_helper.rb require 'openapi_first' OpenapiFirst::Test.setup
-
Observe your application. If you are using plain rack-test (sinatra, roda, hanami, etc.), this will add an
appmethod that wraps your application with silent request / response validation:RSpec.configure do |config| config.include OpenapiFirst::Test::Methods[MyApp], type: :request end
There are other ways to monitor your app, see
OpenapiFirst::Test.observe(rails) orOpenapiFirst::Test.app. -
Run your tests. The Coverage feature will tell you about missing or invalid requests/responses:
✓ GET /stations ✓ 200(application/json) ❌ 200(application/xml) – No responses tracked! ❌ 400(application/problem+json) – No responses tracked!Now add tests for all those "❌" to make them "✓" and you're green!
Note
Check out faraday-openapi to have your API client validate request/responses against an OAD, which is useful to validate HTTP mocks during testing.
You can ignore errors for certain requests/responses. This will stop OpenapiFirst::Test from raising an exception if the block returns true.
OpenapiFirst::Test.setup do |test|
test.ignore_request_error do |validated_request|
# Ignore unknown requests on certain paths
validated_request.path.start_with?('/api/v1') && validated_request.unknown?
end
test.ignore_response_error do |validated_response, rack_request|
# Ignore invalid response bodies on certain paths
validated_request.path.start_with?('/api/legacy/stuff') && validated_request.error.type == :invalid_body
end
endOpenapiFirst::Test raises an error when a response status is not defined except for 404 and 500. You can change this:
OpenapiFirst::Test.setup do |test|
test.ignored_unknown_status << 403
endOr you can ignore all unknown response status:
OpenapiFirst::Test.setup do |test|
test.ignore_unknown_response_status = true
endExclude certain responses from coverage with skip_coverage:
OpenapiFirst::Test.setup do |test|
test.skip_response_coverage do |response_definition|
response_definition.status == '5XX'
end
endSkip coverage for a request and all responses alltogether of a route with skip_coverage:
OpenapiFirst::Test.setup do |test|
test.skip_coverage do |path, request_method|
path == '/bookings/{bookingId}' && requests_method == 'DELETE'
end
endOpenapiFirst::Test raises an error when a request is not defined. You can deactivate this with:
OpenapiFirst::Test.setup do |test|
test.ignore_unknown_requests = true
endWarning
You probably don't need this. Just setup Contract testing and API coverage and use your normal assertions.
openapi_first ships with a simple but powerful Test method assert_api_conform to run request and response validation in your tests without using the middlewares. This is designed to be used with rack-test.
Here is how to use it with RSpec, but MiniTest works just as good:
# spec_helper.rb
OpenapiFirst::Test.setup do |test|
test.register(File.join(__dir__, '../examples/openapi.yaml'), as: :example_app)
endInside your test :
RSpec.describe 'Example App' do
include Rack::Test::Methods
include OpenapiFirst::Test::Methods[App]
it 'is API conform' do
get '/'
assert_api_conform(status: 200)
end
endLoad the API description:
require 'openapi_first'
definition = OpenapiFirst.load('openapi.yaml')validated_request = definition.validate_request(rack_request)
# Inspect the request and access parsed parameters
validated_request.valid?
validated_request.invalid?
validated_request.error # => Failure object or nil
validated_request.parsed_body # => The parsed request body (Hash)
validated_request.parsed_query # A Hash of query parameters that are defined in the API description, parsed exactly as described.
validated_request.parsed_path_parameters
validated_request.parsed_headers
validated_request.parsed_cookies
validated_request.parsed_params # Merged parsed path, query parameters and request body
# Access the Openapi 3 Operation Object Hash
validated_request.operation['x-foo']
validated_request.operation['operationId'] => "getStuff"
# or the whole request definition
validated_request.request_definition.path # => "/pets/{petId}"
validated_request.request_definition.operation_id # => "showPetById"
# Inspect the parameters that are defined for this request, in the order path, query, header, cookie.
# Parameters that openapi_first ignores (Content-Type, Accept, Authorization) are not included.
parameter = validated_request.request_definition.parameters.first
parameter.name # => "petId"
parameter.location # => "path" ("path", "query", "header" or "cookie")
parameter.schema # => { "type" => "integer" } (the JSON Schema, with $refs resolved)
parameter.required? # => true
parameter.deprecated? # => false
parameter.style # => "simple"
parameter.explode? # => false
parameter.media_type # => "application/json" if the parameter uses `content`, otherwise nil
# Or you can raise an exception if validation fails:
definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalidvalidated_response = definition.validate_response(rack_request, rack_response)
# Inspect the response and access parsed parameters and
validated_response.valid?
validated_response.invalid?
validated_response.error # => Failure object or nil
validated_response.status # => 200
validated_response.parsed_body
validated_response.parsed_headers
# Or you can raise an exception if validation fails:
definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundErrorYou can integrate your code at certain points during request/response validation via hooks.
Available hooks:
before_request_validationafter_request_validationafter_response_validationafter_request_parameter_property_validationafter_request_body_property_validationafter_response_body_property_validation
Setup per per instance:
OpenapiFirst.load('openapi.yaml') do |config|
config.after_request_validation do |validated_request|
validated_request.valid? # => true / false
end
config.after_response_validation do |validated_response, request|
if validated_response.invalid?
warn "#{request.request_method} #{request.path}: #{validated_response.error.message}"
end
end
endSetup globally:
OpenapiFirst.configure do |config|
config.after_request_parameter_property_validation do |data, property, property_schema|
data[property] = Date.iso8601(data[property]) if property_schema['format'] == 'date'
end
endUsing rack middlewares is supported in probably all Ruby web frameworks.
The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
That aside, closer integration with specific frameworks like Hanami, Roda or others would be great. If you have ideas, pain points or PRs, please don't hesitate to share.
OpenapiFirst::Sinatra is a Sinatra extension to define routes by referencing operations in your OpenAPI description. The URL and HTTP method for each route are read from the OAD by operationId, so they are never repeated in your code – the API description stays the single source of truth for your routes.
require 'sinatra/base'
require 'openapi_first/sinatra'
class PetsApi < Sinatra::Base
register OpenapiFirst::Sinatra
openapi 'openapi.yaml'
operation :index_pets do |params|
json index_pets(params[:filter])
end
operation :create_pet do
json create_pet(parsed_body[:data])
end
endIn a classic (top-level) app the extension registers itself, so requiring the file is enough:
require 'sinatra'
require 'openapi_first/sinatra'
openapi 'openapi.yaml'
operation :create_pet do
json create_pet(parsed_body)
endEach operation route validates its request against the description before the block runs, so contract violations return 400/415 and the block is not reached. Validation reuses Sinatra's own routing (the operation's path and method are known when the route is defined), so openapi_first does not run its own router – there is no request-validation middleware.
Because routing is left to Sinatra, requests to paths without an operation block fall through to Sinatra's normal handling (a 404 by default), and you can add plain Sinatra routes (health checks, assets, …) alongside operation blocks. Likewise, an undocumented method on a documented path returns Sinatra's 404.
Use a String for operationIds that are not valid Ruby symbols, e.g. operation 'pets.list'.
See sinatra.rb for a more details.
This gem was inspired by committee (Ruby) and Connexion (Python). Here is a feature comparison between openapi_first and committee.
Let's say you have openapi.yaml like this:
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:
oad = OpenapiFirst.load('openapi.yaml') do |config|
config.path = ->(req) { request.path.delete_prefix('/api') }
end
use OpenapiFirst::Middlewares::RequestValidation, oadRun git submodule update --init to initialize the git submodules.
Run bin/setup to install dependencies.
See bundle exec rake to run the linter and the tests.
Run bundle exec rspec to run the tests only.
Run benchmarks:
cd benchmarks
bundle
bundle exec ruby benchmarks.rbIf you have a question or an idea or found a bug, don't hesitate to create an issue on Github or Codeberg or say hi on Mastodon (ruby.social).
Pull requests are very welcome as well, of course. Feel free to create a "draft" pull request early on, even if your change is still work in progress. 🤗