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
Add the following line to your Gemfile and then run bundle install:
Call Explicit::Request.new to define a request. The following methods are available:
- get(path) - Adds a route to the request. Use the syntax /:param for path
params.
- There is also head, post, put, delete, options and patch for other HTTP verbs.
- 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:
Types are just data. You can share types the same way you reuse constants or configs in your app. For example:
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:
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.
For Minitest users, add the following line to your test/test_helper.rbTo 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 as fetch(CustomerController::UpdateRequest, { customer_id: 123 }).
Note: Response types are only verified in test environment with no performance penalty when running in production.
Minitest exampleCall 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 section
- add(title:, partial:) - Adds a partial to the section
For example:
Explicit::Documentation.new returns a rails engine that you can mount in your config/routes.rb. For example:
You can add request examples in two different ways:
- Manually add an example with example(params:, headers:, response:)
- Automatically save examples from tests
In a request, call example(params:, headers:, response:) after declaring params and responses. It's important the example comes after params and responses to make sure it actually follows the type definition.
For example:
Request examples are just data, so you can extract and reference them in any way you like. For example:
The fetch method provided by Explicit::TestHelper accepts the option add_as_example. When set to true, the request example is persisted to a local file. For example:
Whenever you wish to refresh the examples file run the test suite with the ENV UPDATE_REQUEST_EXAMPLES set. For example UPDATE_REQUEST_EXAMPLES=true bin/rails test or UPDATE_REQUEST_EXAMPLES=true bundle exec rspec. The file is located at #{Rails.root}/public/explicit_request_examples.json by default, but you can change it here.
Important: be careful not to leak any sensitive data when persisting examples from tests
You can expose your API endpoints as tools for chat clients by mounting an MCP server. The MCP server acts as a proxy receiving tool calls and forwarding them to your existing REST API controllers. Your controllers remain the source of truth and the MCP server simply provides a tool-compatible interface.
To build an MCP server, instantiate ::Explicit::MCPServer and add the requests you wish to expose. The following methods are available:
- name(str) - Sets the name of the MCP Server which is displayed in the MCP client.
- version(str) - Sets the version of the MCP server which is displayed in the MCP client
- add(request) - Exposes a request as a tool in the MCP server.
For example:
Then, mount the MCP Server in your routes.rb:
The following methods are available in Explicit::Request to configure the MCP tool. They're all optional and the MCP server still works correctly using the request's default title, description and params.
- mcp_tool_name(name) - Sets the unique identifier for the tool. Should be a string with only ASCII letters, numbers and underscore. By default it is set to a normalized version of the route's path.
- mcp_tool_description(description) - Sets the description of the tool. Markdown supported. By default it is set to the request description.
- mcp_tool_title(title) - Sets the human readable name for the tool. By default it is set to the request's title.
- mcp_tool_read_only_hint(true/false) - If true, the tool does not modify its environment.
- mcp_tool_destructive_hint(true/false) - If true, the tool may perform destructive updates.
- mcp_tool_idempotent_hint(true/false) - If true, repeated calls with same args have no additional effect.
- mcp_tool_open_world_hint(true/false) - If true, tool interacts with external entities.
For example:
There are two considerations when securing your MCP server:
- Authorize the MCP tool call You should authorize the action based on a unique attribute present in the request's params or headers. For example, you should share a URL with your customers similar to this one: https://myapp.com/api/v1/mcp?key=d17c08d5-968c-497f-8db2-ec958d45b447. Then, in the authorize method, you'd use the key to find the user/customer/account.
- Authenticate the REST API Your API probably has an authentication mechanism that is different from the MCP server, such as bearer tokens specified in request headers. To authenticate the API you can either 1) use proxy_with(headers:) or 2) share the current user using ActiveSupport::CurrentAttributes.
To secure your MCPServer you must implement the authorize method in your Explicit::MCPServer. This method is invoked on all requests received by the MCP server. The following arguments are given to authorize:
- params - hash with request's query string values
- headers - hash with the request's HTTP headers
If you return false then the request will be rejected immediatly without ever hitting your API controllers. For example:
A boolean that must always be true. Useful for terms of use or agreement acceptances. The following values are accepted: true, "true", "on", "1" and 1.
Allows all values, including null. Useful when documenting a proxy that responds with whatever value the other service returned.
All items in the array must be valid according to the subtype. If at least one value is invalid then the array is invalid.
Value must be an integer or a string like "0.2" to avoid rounding errors. Reference
The following values are true: true, "true", "on", "1" and 1, and the following values are false: false, "false", "off", "0" and 0.
A date in the format of "YYYY-MM-DD".
A range between two dates in the format of "YYYY-MM-DD..YYYY-MM-DD". The range is inclusive.
String encoded date time following the ISO 8601 spec. For example: "2024-12-10T14:21:00Z"
A range between two date times in the format of "start_date_time..end_date_time". For example: "2024-12-10T14:00:00Z..2024-12-11T15:00:00Z". The range is inclusive.
The number of elapsed seconds since January 1, 1970 in timezone UTC. For example: 1733923153
Provides a default value for the param if the value is not present or it is nil. Other falsy values such as empty string or zero have precedence over the default value.
If you provide a lambda it will execute every time Request.validate! is called.
Adds a description to the type. Descriptions are displayed in documentation and do not affect validation in any way with. There is no overhead at runtime. Markdown supported.
Hashes are key value pairs where all keys must match key_type and all values must match value_type. If you are expecting a hash with a specific set of keys use a record instead.
Value must be an uploaded file using "multipart/form-data" encoding.
Float encoded string alues such as "0.5" or "500.01" are automatically converted to Float.
Integer encoded string values such as "10" or "-2" are automatically converted to Integer.
A literal value behaves similar to an enum with a single value. Useful for matching against multiple types in one_of.
Value must either match the subtype or be nil.
Attempts to validate against each type in order stopping at the first type that successfully matches the value. If none of the types match, an error is returned.
Records are hashes with predefined attributes. Records support recursive definitions, that is, you can have records inside records, array of records, records with array of records, etc.
Add an initializer config/initializers/explicit.rb with the following code, and then make the desired changes to the config.
Copy the default error messages translations to your project and make the desired changes.
First disable the default response:
and then add a custom rescue_from Explicit::Request::InvalidParamsError to your base controller. Use the following code as a starting point:
When adding a request example with example(params:, response:), the response must match the documented types. If it doesn't match, Explicit logs a warning, but you can choose a more strict behaviour that raises an error instead.
Enable/disable CORS headers support.