another_api
A Rails engine for building opinionated JSON APIs. Wires together
api_serializer,
api_query_language,
api_query_language-active_record,
ActionPolicy, and
Dry::Monads behind a batteries-included
API base controller: bearer-token auth, scoped policies, typed responses,
structured errors, pagination, and filter/sort query-string parsing.
Part of the another_api family.
Installation
gem "another_api"
Configuration
# config/initializers/another_api.rb
AnotherApi.configure do |c|
c.token_model = "ApiToken" # your AR model
c.token_secret = Rails.application.credentials.api_token_secret
c.token_prefix = "my_app" # default: "aa"
c.scope_prefix = "api.v1." # default: "api.v2."
c.default_page_size = 20 # same as default
c.max_page_size = 200 # same as default
# Map application exception classes to HTTP error types:
c.rescue_from "MyApp::NotAllowedError", as: :forbidden
end
AnotherApi.configuration.validate! # fail fast at boot if required
# settings (token_secret, token_model)
# are missing. Otherwise you get a 500
# on the first request instead.
AnotherApi::Scopes.define do
scope :"account.users"
scope :"selling.products", only: [:list, :show]
end
Token model
Your ApiToken model includes the contract module:
class ApiToken < ApplicationRecord
include AnotherApi::ApiTokenContract
serialize :scopes, coder: JSON, type: Array
end
The contract requires columns: token_digest, token_prefix, token_suffix,
scopes, polymorphic bearer_id/bearer_type, plus optional revoked_at and
expires_at. See
api_token_contract.rb for the full
contract.
ApiTokenContract adds these instance methods:
allows?(scope)— true if the token's parsed scopes coverscope.parsed_scopes— memoised array ofAnotherApi::Scopestructs.active?/revoked?/expired?— lifecycle predicates.BaseController's authentication already checksactive?; you do not need to re-check in controllers.token_preview—"aa****…****1234"for safe display in admin UIs.ApiToken.find_by_token(raw_token)— HMAC-digest lookup.
Building a controller
class Api::V1::WidgetsController < AnotherApi::BaseController
include Dry::Monads[:result]
def index
api_respond_to_json do
with: Api::V1::WidgetsPolicy
Success(data: Widget.all.map { |w| w.attributes.slice("id", "name") })
end
end
end
with a matching policy:
class Api::V1::WidgetsPolicy < AnotherApi::ApiTokenScopedPolicy
private
def scope_group_name = :widgets
end
scope_group_name is required on every ApiTokenScopedPolicy
subclass — omitting it raises NoMethodError at authorization time (not
a policy denial). Return the Symbol that matches your
AnotherApi::Scopes.define entry.
For resource-ownership checks (as opposed to scope checks), inherit
AnotherApi::ApiTokenOwnershipPolicy instead and override owned?.
That gets you bearer-token auth, scope checks, standardised error responses,
and Dry::Monads result handling out of the box. See
test/dummy/ for a full example app used by the test suite.
Result handling
Controller blocks return a Dry::Monads::Result. On Failure, return an
AnotherApi::OperationFailure value with a mapped error type:
Failure(AnotherApi::OperationFailure.new(type: :not_found, message: "…"))
type: must be a key in configuration.error_status_map (or match a
rescue_from entry); ResponseHandler converts it to the matching HTTP
status and a standardised JSON error body.
Utilities
AnotherApi::ParamSanitizer#sanitise_query_param(str, max_length:, strip_out:)— length-caps a param and optionally strips characters.strip_out:takes a regex; only pass trusted, non-user-supplied patterns (it reachesgsubdirectly, so catastrophic backtracking is on you).AnotherApi::Paginated— reads?page=/?page_size=, clamps toconfiguration.default_page_size/.max_page_size.AnotherApi::ResponseHasMetadata— builds the envelope'smeta:block (offset, count, total, poll-interval hint).
OpenAPI 3.1 generation
Opt-in. Add require "another_api/openapi" (typically in an initialiser)
and you get a generator that walks your api_serializer schemas and the
controllers that include the EndpointMetadata DSL, and emits an OpenAPI
3.1 spec. The output assumes another_api's response envelope ({success:,
data:, metadata:}) and pagination shape; if your API ships something
different, subclass PathBuilder or override error_response_content /
pagination_metadata_schema in configuration.
# config/initializers/another_api_openapi.rb
require "another_api/openapi"
AnotherApi::OpenAPI.configure do |c|
c.title = "My API"
c.version = "1.0"
c.description = "Public REST API"
c.path_prefix = "/api/v1" # stripped from route paths
c.controllers_glob = "app/controllers/api/v1/**/*_controller.rb"
c.schema_namespace_prefix = "MyApp::Schemas::V1::" # auto-discovers nested schemas
c.default_variant_name = :default # which api_serializer variant
# maps to "<Schema>Full" output
# If your app has its own filtering concerns:
c.register_concern "MyApp::FilterOnDeleted", :filter_on_deleted
c.register_concern "MyApp::FilterOnActive", :filter_on_active
end
Declare endpoint metadata on each controller:
class Api::V1::UsersController < AnotherApi::BaseController
include AnotherApi::OpenAPI::EndpointMetadata
api_resource "Users",
schema: -> { MyApp::Schemas::V1::User },
description: "Manage users in your account"
api_action :index, summary: "List users"
api_action :show, summary: "Get a single user"
api_action :create, summary: "Create a user"
api_action :update, summary: "Update a user"
end
Generate the spec:
spec = AnotherApi::OpenAPI::Generator.generate
spec_json = AnotherApi::OpenAPI::SpecRenderer.render_json # cached, mtime-checked in dev
SpecRenderer caches the result for the lifetime of the process in
production and recomputes it when files under configuration.watched_dirs
change in development. Call SpecRenderer.reset! from a Rails reloader
hook if you need finer control.
Index parameter advertising
PathBuilder advertises these query-string parameters on :index
operations only when the controller's ancestors include the
corresponding another_api concern:
| Parameter | Required concern |
|---|---|
page, page_size |
AnotherApi::Paginated |
filter, sort |
AnotherApi::FilteredAndSorted |
variant |
AnotherApi::SchemaConfigurable |
deleted |
concern key :filter_on_deleted (register via c.register_concern) |
active |
concern key :filter_on_active |
Because AnotherApi::BaseController already includes Paginated and
SchemaConfigurable, every controller that inherits from it advertises
page / page_size / variant by default. filter / sort only
appear if the controller explicitly includes FilteredAndSorted.
Reference example
A working end-to-end example lives in this gem's dummy app — see
test/dummy/app/controllers/test/posts_controller.rb and
test/dummy/app/controllers/test/widgets_controller.rb for the DSL,
test/dummy/config/initializers/another_api_openapi.rb for the
configuration block, and test/openapi/integration_test.rb for an
end-to-end assertion suite that generates and inspects the resulting
spec.
Schema-name conventions
OpenAPI component names are derived from the schema's class name. With
schema_namespace_prefix set, the prefix is stripped and remaining
sub-namespaces are concatenated (e.g.
MyApp::Schemas::V1::Seller::Cart → SellerCart); without a prefix,
demodulize is used. Variant suffixes:
- the configured
default_variant_name→"Full"(e.g.UserFull) :id_only→IdOnly- any other variant →
CamelCaseof the variant symbol - deserializer variants →
<Name><VariantCamel>Input
License
MIT. See LICENSE.txt at the repository root.