Mountapi
Given an OpenAPI 3 spec:
- expose routes through rack http endpoints
- type cast request parameters against specs
- validate request parameters against specs
- call handler with nice parameter hash
- format responses
- handle parameters failure, and route not found errors with details and status codes
For examples see example and test/mountapi/rack_app_test.rb
Important warning
If you found this gem while browsing Rubygems, be aware that this release is
still experimental and meant to be use internally
only.
We will not provide any support regarding this gem. There are some issues we
want to sort out before releasing a real public version.
For instance, we had to fork the
json-schema gem to include a fix. We
can't reference this fork from github since gemspec doesn't allow this. As a
result, we put our fork in vendor
directory which is not the cleanest thing to
do but the only one working at the moment.
Installation
Add this line to your application's Gemfile:
gem 'mountapi'
And then execute:
$ bundle
Or install it yourself as:
$ gem install mountapi
Usage
Tutorial
Rails
def private_gem(name, tag: nil, path: nil)
var_env = ENV["BUNDLE_LOCAL__#{name.upcase}"]
if var_env.nil? || var_env.empty?
gem name, tag
elsif File.directory?(path)
gem name, path: path
end
end
source "https://gems.synbioz.com" do
private_gem "mountapi", tag: "0.6.0", path: "/mountapi"
end
With this helper you can work with the gem locally in the context.
You have to specify the variable BUNDLE_LOCAL__MOUNTAPI=/mountapi
in ops/dev/app_env
and mount a specific volume in the docker-compose of dev:
services:
app:
...
volumes:
- app_data:/app
- mountapi:/mountapi
...
volumes:
...
mountapi:
driver_opts:
type: none
device: "/Users/jonathanfrancois/code/synbioz/mountapi"
o: bind
...
Add a dedicated initializer:
Rails.application.configure do
require "mountapi"
config.before_initialize do
config.middleware.insert_before Warden::Manager, Mountapi::RackApp
end
HANDLERS = "#{Rails.root}/app/mount_api/handlers"
require "#{HANDLERS}/base_handler.rb"
unless Rails.env.production?
Dir.glob("#{HANDLERS}/**/*.rb") { |f| require f }
config.to_prepare do
Dir.glob("#{HANDLERS}/**/*.rb") { |f| load f }
end
end
end
Mountapi.configure do |config|
config.open_api_spec = File.read(File.expand_path("../../api.yaml", __dir__))
end
require "mountapi/doc_rack_app"
Add a valid OpenAPI 3 spec file to rails root directory #{Rails.root}/api.yml
.
And add your handlers in specific directory #{Rails.root}/app/mount_api/handlers/
.
It is important to use this folder and to use a Handler
module in order to allow Zeitwerk to auto-load the classes.
We will usually use a basic handler and include Mountapi::Handler::Behaviour
.
By the way, this file is explicitly required in the initializer.
module Handlers
class BaseHandler
include Mountapi::Handler::Behaviour
end
end
One can list all routes detected and handled by MountAPI by using the
mountapi:routes
Rake task.
Ruby app
Given a powerful micro-service that just return the parameters it receive:
module SliceArray
def self.call(array:, start:, length:)
array.slice(start, length)
end
end
And a valid OpenAPI 3 spec file to expose the service
openapi: 3.0.0
info:
version: 1.0.0
title: Test
paths:
/array/slicer:
get:
operationId: SliceArray
parameters:
- name: target
in: query
schema:
type: array
items:
type: string
- name: limit
in: query
schema:
type: integer
required: true
- name: offset
in: query
required: true
schema:
type: integer
responses:
"200":
description: success
"400":
description: error in request
In order to serve your endpoint specification through HTTP, you just need two things:
First Create a handler
module SliceArray
class MountapiHandler
include Mountapi::Handler::Behaviour
operation_id "SliceArray"
def call
data = SliceArray.call(params)
ok(data: data)
end
end
end
Second, add a rackup file in order to boot your rack endpoint
require "bundler"
Bundler.require
require "slice-array"
require "slice-array/handler/behaviour"
require "mountapi"
require "mountapi/rack_app"
Mountapi.configure do |config|
config.open_api_spec = ["../open_api_v1.yml", "../open_api_v2.yml"].map { |path| File.read(File.expand_path("../open_api.yml", __FILE__)) }
end
run Mountapi::RackApp.new
If you rackup
you'll get your service wired through HTTP !
curl "localhost:9292/1.0.0/array/slicer?target[]=a&target[]=b&target[]=c&limit=1&offset=1"
{"data":["b"]}%
curl localhost:9292/1.0.0/array/slicer
{"errors":"[\"The property '#/' did not contain a required property of 'target' in schema ca7afc0e-f3a0-5416-92fa-3a72ffff7fe2\", \"The property '#/' did not contain a required property of 'limit' in schema ca7afc0e-f3a0-5416-92fa-3a72ffff7fe2\", \"The property '#/' did not contain a required property of 'offset' in schema ca7afc0e-f3a0-5416-92fa-3a72ffff7fe2\"]"}
curl "localhost:9292/1.0.0/array/slicer?limit=foo&offset=1.0"
{"errors":"[{:message=>\"can't cast \\\"foo\\\" as Mountapi::Schema::Integer\"}]"}%
Play with the example in example/
Requests conventions
- A JSON request body root MUST be an object
- Default values will be filled in your parameters only for the root level. Default values in nested object are not supported yet (and should not be, because it would add complexity to applications design and API).
Responses schema and validations
If you define responses payload in the YAML, your hanlder response payload must match this definition.
If you don't define a response schema, your response will be validated against JSON API schema.
If the response payload is not valid, Mountapi will raise a Mountapi::Error::InvalidResponse
URL reflection
Within a handler the following methods are available :
base_url
, that returns current request base URLurl_for(operation_id, url_params)
that return the URL for a given operation and parameters (eg. url_for("ShowRevision", uid: revision.uid)
)
Handler implementation
Why a handler ?
The handler is responsible for the integration between infrastructure detail (using Mountapi) and the application services (aka use cases).
Because application code does not know anything about the way you deliver the application to the end user.
You could deliver the content through Mountapi , HTML, Terminal or a mobile phone endpoint. Your application business remain the same, only the transport protocol and representation would change.
Implementation
A Moutapi handler is really a dumb but valuable piece of code.
It has to transform the inbound parameters from Mountapi to a set of parameters required by your use case.
Then will call an application service to fulfill the request, and translate the execution result to Mountapi response.
see lib/mountapi/handler/behaviour
example :
require "gtc/app/show_revision"
module Gtc
module Infra
module Mountapi
class ShowRevision
include ::Mountapi::Handler::Behaviour
operation_id "ShowRevision"
def call
data = Gtc::App::ShowRevision.call(params.fetch("gtc_uid"))
data && ok(data.to_h) || not_found
end
end
end
end
end
Roles handling
If your API is behind an HTTP proxy which provides headers with current user
info then you can take advantage of the user_info_adapter
to automatically
handle authorization before reaching handler call
method.
To take advantage of this, you need to configure user_info_adapter
. This must
be a callable that must return a boolean. If the return value is true
then
call
method will be run otherwise a 403 Forbidden
response will be returned.
Mountapi.configure do |config|
config.user_info_adapter = Mountapi::Adapters::UserInfoAdapter.new("x-user-info", { roles: "roles" })
end
In the previous example, we're using the provided UserInfoAdapter
. It takes
two arguments, the first one is the name of the HTTP header the adapter will
read to gather info about the user. The second argument is a mapping of adapter
internal info and its corresponding name in the header value.
UserInfoAdapter
is expecting the header value to be a base64 encoded JSON
string. This JSON must contain at least a key which value will be an array of
the current user roles.
So in our example we're expecting a x-user-info
HTTP header which is a JSON
string including a key named roles
.
If you need to use HTTP headers in an other way then you can write your own
adapter.
Now that our adapter is configured, we can specify allowed roles in our handler:
class SomeHandler
include Mountapi::Handler::Behaviour
operation_id "operationId"
allowed_roles "admin"
def call
end
end
In this example, we use the allowed_roles
method to specify that only user
with role admin
should be allowed to execute the action. If one of the current
user roles is admin
then we run call
method. If roles aren't matching then
we shortcut the execution by returning a 403 response.
Development
After checking out the repo, run bin/setup
to install dependencies. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
To run containerized tests locally, run ops/test/compose run --rm runner rake test
.
API Documentation
Mountapi allows you to generate the documentation associated with your APIs.
It uses the Redoc service: https://github.com/Redocly/redoc
To do this you need to require the dedicated app rack and mount it in your routes.
For example in the context of a rails project:
...
Mountapi.configure do |config|
config.open_api_spec = File.read(File.expand_path("../../api.yml", __dir__))
end
require "mountapi/doc_rack_app"
and mount the rack app in your router by specifying the namespace (here apidoc
):
# config/routes.rb
mount Mountapi::DocRackApp, at: "/apidoc"
You then can access the documentation of all your versions from the main page /apidoc
.
Contributing
Bug reports and pull requests are welcome on GitLab at https://git.synbioz.com/synbioz/mountapi.