# 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](example/) and [test/mountapi/rack_app_test.rb](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](https://www.synbioz.com). 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](https://github.com/voxpupuli/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: ```ruby gem 'mountapi' ``` And then execute: $ bundle Or install it yourself as: $ gem install mountapi ## Usage ### Tutorial #### Rails ```ruby # Var env BUNDLE_LOCAL__XXX is no longer used by bundler # because we don't have a specified branch # But we use it manually to set the local path 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: ```yaml services: app: ... volumes: - app_data:/app - mountapi:/mountapi ... volumes: ... mountapi: driver_opts: type: none device: "/Users/jonathanfrancois/code/synbioz/mountapi" # CHANGE ME o: bind ... ``` Add a dedicated initializer: ```ruby 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 # Enable documentation # You must add `mount Mountapi::DocRackApp, at: "/apidoc"` in routes.rb 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. ```ruby 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: ```ruby #lib/slice_array.rb # A dumb service example 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 ```yaml 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 ```ruby #/lib/slice_array/mountapi_handler module SliceArray class MountapiHandler include Mountapi::Handler::Behaviour # add the behaviour to the class operation_id "SliceArray" # You must define a call instance method # `ok` and `params` are provided by Handler::Behaviour def call data = SliceArray.call(params) ok(data: data) end end end ``` Second, add a rackup file in order to boot your rack endpoint ```ruby #config.ru 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 URL - `url_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 : ```ruby 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. ```ruby 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: ```ruby class SomeHandler include Mountapi::Handler::Behaviour operation_id "operationId" allowed_roles "admin" def call # do something if user has one of allowed roles 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](https://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: ```ruby # config/initializer/mountapi.rb ... Mountapi.configure do |config| config.open_api_spec = File.read(File.expand_path("../../api.yml", __dir__)) end # You absolutely must require it after the configuration of the Mountapi gem 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 .