OpenapiFirst

Join the chat at https://gitter.im/openapi_first/community

OpenapiFirst helps to implement HTTP APIs based on an OpenApi API description. The idea is that you create an API description first, then add code that returns data and implements your business logic and be done.

Start with writing an OpenAPI file that describes the API, which you are about to implement. Use a validator to make sure the file is valid.

You can use OpenapiFirst via its Rack middlewares or in standalone mode.

Alternatives

This gem is inspired by committee (Ruby) and connexion (Python).

Here's a comparison between committee and openapi_first.

Rack middlewares

OpenapiFirst consists of these Rack middlewares:

OpenapiFirst::Router

You always have to add this middleware first in order to make the other middlewares work.

use OpenapiFirst::Router, spec: OpenapiFirst.load('./openapi/openapi.yaml')

This middleware adds env[OpenapiFirst::OPERATION] which holds an Operation object that responds to #operation_id, #path (and #[] to access raw fields).

Options and defaults

Name Possible values Description Default
spec: The spec loaded via OpenapiFirst.load
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)
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)

OpenapiFirst::RequestValidation

This middleware returns a 400 status code with a body that describes the error if the request is not valid.

use OpenapiFirst::RequestValidation

Options and defaults

Name Possible values Description Default
raise_error: false, true If set to true the middleware raises OpenapiFirst::RequestInvalidError instead of returning 4xx. false (don't raise an exception)

The error responses conform with JSON:API.

Here's an example response body for a missing query parameter "search":

http-status: 400
content-type: "application/vnd.api+json"

{
  "errors": [
    {
      "title": "is missing",
      "source": {
        "parameter": "search"
      }
    }
  ]
}

This middleware adds env[OpenapiFirst::INBOX] which holds the (filtered) path and query parameters and the parsed request body.

Parameter validation

The middleware filteres all top-level query parameters and paths parameters and tries to convert numeric values. Meaning, if you have an :something_id path with type: integer, it will try convert the value to an integer.

It just works with a parameter with name: filter[age].

OpenapiFirst also supports type: array for query parameters and will convert items just as described above. style and explode attributes are not supported for query parameters. It will always act as if style: form and explode: false were used for query parameters.

Conversion is currently done only for path and query parameters, but not for the request body. OpenapiFirst currently does not convert date, date-time or time formats.

If you want to forbid nested query parameters you will need to use additionalProperties: false in your query parameter JSON schema.

OpenapiFirst always treats query parameters like style: deepObject, but it just works with nested objects (filter[foo][bar]=baz) (see this discussion).

Request body validation

The middleware will return a status 415 if the requests content type does not match or 400 if the request body is invalid. This will also add the parsed request body to env[OpenapiFirst::REQUEST_BODY].

tbd.

readOnly / writeOnly properties

Request validation fails if request includes a property with readOnly: true.

Response validation fails if response body includes a property with writeOnly: true.

OpenapiFirst::Responder

This Rack endpoint maps the HTTP request to a method call based on the operationId in your API description and calls it. Responder also adds a content-type to the response.

run OpenapiFirst::Responder, spec: OpenapiFirst.load('./openapi/openapi.yaml')

Options

Name Description
namespace: Optional. A class or module where to find the handler method.
resolver: Optional. An object that responds to #call(operation) and returns a (handler)[#handler]. By default this is an instance of DefaultOperationResolver

OpenapiFirst::DefaultOperationResolver

This is the default way to look up a handler method for an operation. Handlers are always looked up in a namespace module that needs to be specified.

It works like this:

  • An operationId "create_pet" or "createPet" or "create pet" calls MyApi.create_pet(params, response)
  • "some_things.create" calls: MyApi::SomeThings.create(params, response)
  • "pets#create" calls: MyApi::Pets::Create.new.call(params, response) If MyApi::Pets::Create.new accepts an argument, it will pass the rack env.

Handlers

These handler methods are called with two arguments:

  • params - Holds the parsed request body, filtered query params and path parameters (same as env[OpenapiFirst::INBOX])
  • res - Holds a Rack::Response that you can modify if needed

You can call params.env to access the Rack env (just like in Hanami actions)

There are two ways to set the response body:

  • Calling res.write "things" (see Rack::Response)
  • Returning a value which will get converted to JSON

OpenapiFirst::ResponseValidation

This middleware is especially useful when testing. It always raises an error if the response is not valid.

use OpenapiFirst::ResponseValidation if ENV['RACK_ENV'] == 'test'

Standalone usage

Instead of composing these middlewares yourself you can use OpenapiFirst.app.

module Pets
  def self.find_pet(params, res)
    {
      id: params[:id],
      name: 'Oscar'
    }
  end
end

# In config.ru:
require 'openapi_first'
run OpenapiFirst.app(
  './openapi/openapi.yaml',
  namespace: Pets,
  response_validation: ENV['RACK_ENV'] == 'test',
  router_raise_error:  ENV['RACK_ENV'] == 'test'
)

The above will use the mentioned Rack middlewares to:

  • Validate the request and respond with 400 if the request does not match with your API description
  • Map the request to a method call Pets.find_pet based on the operationId in the API description
  • Set the response content type according to your spec (here with the default status code 200)

Options and defaults

Name Possible values Description Default
spec_path A filepath to an OpenAPI definition file.
namespace: A class or module where to find the handler methods.
response_validation: true, false If set to true it raises an exception if the response is invalid. This is useful during testing. false
router_raise_error: true, false If set to true it raises an exception (subclass of OpenapiFirst::Error when a request path/method is not specified. This is useful during testing. false
request_validation_raise_error: true, false If set to true it raises an exception (subclass of OpenapiFirst::Error when a request is not valid. false
resolver: Option to customize finding the handler method for an operation. See OpenapiFirst::Responder for details.

Handler functions (find_pet) are called with two arguments:

  • params - Holds the parsed request body, filtered query params and path parameters
  • res - Holds a Rack::Response that you can modify if needed If you want to access to plain Rack env you can call params.env.

If your API description does not contain all endpoints

run OpenapiFirst.middleware('./openapi/openapi.yaml', namespace: Pets)

Here all requests that are not part of the API description will be passed to the next app.

Try it out

See examples.

Installation

Add this line to your application's Gemfile:

gem 'openapi_first'

OpenapiFirst uses multi_json.

Manual response validation

Instead of using the ResponseValidation middleware you can validate the response in your test manually via rack-test and ResponseValidator.

# In your test (rspec example):
require 'openapi_first'
spec = OpenapiFirst.load('petstore.yaml')
validator = OpenapiFirst::ResponseValidator.new(spec)

# This will raise an exception if it found an error
validator.validate(last_request, last_response)

Handling only certain paths

You can filter the URIs that should be handled by passing only to OpenapiFirst.load:

spec = OpenapiFirst.load './openapi/openapi.yaml', only: '/pets'.method(:==)
run OpenapiFirst.app(spec, namespace: Pets)

Coverage

(This is a bit experimental. Please try it out and give feedback.)

OpenapiFirst::Coverage helps you make sure, that you have called all endpoints of your OAS file when running tests via rack-test.

# In your test (rspec example):
require 'openapi_first/coverage'

describe MyApp do
  include Rack::Test::Methods

  before(:all) do
    spec = OpenapiFirst.load('petstore.yaml')
    @app_wrapper = OpenapiFirst::Coverage.new(MyApp, spec)
  end

  after(:all) do
    message = "The following paths have not been called yet: #{@app_wrapper.to_be_called}"
    expect(@app_wrapper.to_be_called).to be_empty
  end

  # Overwrite `#app` to make rack-test call the wrapped app
  def app
    @app_wrapper
  end

  it 'does things' do
    get '/i/my/stuff'
    # …
  end
end

Mocking

Out of scope. Use Prism or fakeit.

Development

Run bin/setup to install dependencies.

Run bundle exec rspec to run the tests.

See bundle exec rake -T for rubygems related tasks.

Run benchmarks

cd benchmarks
bundle
bundle exec ruby benchmarks.rb

Contributing

If you have a question or an idea or found a bug don't hesitate to create an issue on GitHub or reach out via chat.

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. 🤗