Explicit
Explicit is a validation and documentation library for REST APIs that enforces documented types at runtime
Install / Use
/learn @luizpvas/ExplicitREADME
Explicit
Explicit is a validation and documentation library for REST APIs that enforces documented types at runtime.
|
|
| :-----------------------------------------------------------------------------------------------------------------------------------: |
| Click here to visit the example documentation page |
- Installation
- Defining requests
- Reusing types
- Reusing requests
- Writing tests
- Publishing documentation
- MCP
- Types
- Configuration
Installation
Add the following line to your Gemfile and then run bundle install:
gem "explicit", "~> 0.2"
Defining requests
Call Explicit::Request.new to define a request. The following methods are
available:
get(path)- Adds a route to the request. Use the syntax/:paramfor path params.- There is also
head,post,put,delete,optionsandpatchfor other HTTP verbs.
- There is also
title(text)- Adds a title to the request. Displayed in documentation.description(text)- Adds a description to the endpoint. Displayed in documentation. Markdown supported.header(name, type, options = {})- Adds a type to the request header.param(name, type, options = {})- Adds a type to the request param. It works for params in the request body, query string and path params.- The following options are available:
optional: true- Makes the param nilable.default: value- Sets a default value to the param, which makes it optional.description: "text"- Adds a documentation to the param. Markdown supported.
- The following options are available:
response(status, type)- Adds a response type. You can add multiple responses with different formats.example(params:, headers:, response:)- Adds an example to the documentation. See more details here.base_url(url)- Sets the host for this API. For example: "https://api.myapp.com". Meant to be used with request composition.base_path(prefix)- Sets a prefix for the routes. For example: "/api/v1". Meant to be used with request composition.
For example:
class RegistrationsController < ActionController::API
Request = Explicit::Request.new do
post "/api/registrations"
description <<~MD
Attempts to register a new user in the system. If `payment_type` is not
specified a trial period of 30 days is started.
MD
param :name, [:string, empty: false]
param :email, [:string, format: URI::MailTo::EMAIL_REGEXP, strip: true]
param :payment_type, [:enum, ["free_trial", "credit_card"]], default: "free_trial"
param :terms_of_use, :agreement
response 200, { user: { id: :integer, email: :string } }
response 422, { error: "email_already_taken" }
end
def create
Request.validate!(params) => { name:, email:, payment_type: }
user = User.create!(name:, email:, payment_type:)
render json: { user: user.as_json(only: %[id email]) }
rescue ActiveRecord::RecordNotUnique
render json: { error: "email_already_taken" }, status: 422
end
end
Reusing types
Types are just data. You can share types the same way you reuse constants or configs in your app. For example:
module MyApp::Type
UUID = [:string, format: /^\h{8}-(\h{4}-){3}\h{12}$/].freeze
EMAIL = [:string, format: URI::MailTo::EMAIL_REGEXP, strip: true].freeze
ADDRESS = {
country_name: [:string, empty: false],
zipcode: [:string, format: /\d{6}-\d{3}/]
}.freeze
end
# ... and then reference the shared types when needed
Request = Explicit::Request.new do
param :customer_uuid, MyApp::Type::UUID
param :email, MyApp::Type::EMAIL
param :address, MyApp::Type::ADDRESS
end
Reusing requests
Sometimes it is useful to share a group of params, headers or responses between
requests. You can achieve this by instantiating requests from an existing
request instead of Explicit::Request. For example:
AuthenticatedRequest = Explicit::Request.new do
base_url "https://my-app.com"
base_path "/api/v1"
header "Authorization", [:string, format: /Bearer [a-zA-Z0-9]{20}/], auth: :bearer
response 403, { error: "unauthorized" }
end
Request = AuthenticatedRequest.new do
# Request inherits all definitions from AuthenticatedRequest.
# Any change you make to params, headers, responses or examples will add to
# existing definitions.
end
Writing tests
Include Explicit::TestHelper in your test/test_helper.rb or
spec/rails_helper.rb. This module provides the method
fetch(request, **options) that let's you verify the endpoint works as
expected and that it responds with a valid response according to the docs.
module ActiveSupport
class TestCase
fixtures :all
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
+ include Explicit::TestHelper
end
end
</details>
<details open>
<summary>For RSpec users, add the following line to your <code>spec/rails_helper.rb</code></summary>
RSpec.configure do |config|
+ config.include Explicit::TestHelper
end
</details>
To test your controller, call fetch(request, **options) and write
assertions against the response. If the response is invalid the test fails with
Explicit::Request::InvalidResponseError.
The response object has a status, an integer value for the http status, and
data, a hash with the response data. It also provides dig for a
slighly shorter syntax when accessing nested attributes.
Path params are matched by name, so if you have an endpoint configured with
put "/customers/:customer_id"you must call asfetch(CustomerController::UpdateRequest, { customer_id: 123 }).
<details open> <summary>Minitest example</summary>Note: Response types are only verified in test environment with no performance penalty when running in production.
class API::V1::RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "successful registration" do
response = fetch(RegistrationsController::Request, params: {
name: "Bilbo Baggins",
email: "bilbo@shire.com",
payment_type: "free_trial",
terms_of_use: true
})
assert_equal 200, response.status
assert_equal "bilbo@shire.com", response.dig(:user, :email)
end
end
</details>
<details open>
<summary>RSpec example</summary>
describe RegistrationsController::Request, type: :request do
context "when request params are valid" do
it "successfully registers a new user" do
response = fetch(described_class, params: {
name: "Bilbo Baggins",
email: "bilbo@shire.com",
payment_type: "free_trial",
terms_of_use: true
})
expect(response.status).to eql(200)
expect(response.dig(:user, :email)).to eql("bilbo@shire.com")
end
end
end
</details>
Publishing documentation
Call Explicit::Documentation.new to group, organize and publish the
documentation for your API. The following methods are available:
page_title(text)- Sets the web page title.company_logo_url(url)- Shows the company logo in the navigation menu. The url can also be a lambda that returns the url, useful for referencing assets at runtime.favicon_url(url)- Adds a favicon to the web page.version(semver)- Sets the version of the API. Default: "1.0"section(name, &block)- Adds a section to the navigation menu.add(request)- Adds a request to the sectionadd(title:, partial:)- Adds a partial to the section
For example:
module MyApp::API::V1
Documentation = Explicit::Documentation.new do
page_title "Acme API Docs"
company_logo_url "https://my-app.com/logo.png"
favicon_url "https://my-app.com/favicon.ico"
version "1.0.5"
section "Introduction" do
add title: "About", partial: "api/v1/introduction/about"
end
section "Auth" do
add RegistrationsController::CreateRequest
add SessionsController::CreateRequest
add SessionsController::DestroyRequest
end
section "Articles" do
add ArticlesController::CreateRequest
add ArticlesController:
