model_driven_api
Part of the Thecore framework.
A Rails engine that auto-generates a versioned REST API by introspecting your ActiveRecord models at runtime. No per-model controllers or serializers needed — the schema drives everything.
| Version | Base path | Format | Query style |
|---|---|---|---|
| v2 | /api/v2/ |
Plain JSON | Ransack predicates (q[field_eq]=value) |
| v3 | /api/v3/ |
JSON:API | filter[field]=value, sort=field, page[number]=N |
Features
- Full CRUD for every
ApplicationRecordsubclass with zero boilerplate - v2: Ransack-powered filtering and sorting (GET and POST search endpoint)
- v3: JSON:API-compliant envelopes; filter/sort/page query params; Pagy pagination
- JWT authentication with sliding token expiration (shared by v2 and v3)
- OAuth2 support: Google Workspace and Microsoft Entra ID
- LDAP / Active Directory authentication (via host app headers)
- Custom actions on any model — two patterns supported (v2 and v3)
- SELECT-only raw SQL endpoint (v2 and v3)
- Self-generated OpenAPI 3.0 / Swagger documentation (v2 and v3)
- JSON:API sideloading with hybrid defaults (
json_attrs[:include]+?include=override) - JSON:API sparse fieldsets (
?fields[type]=f1,f2) Content-Rangeheader for react-admin and similar frontends (v2)
Installation
Add to your host app's Gemfile:
gem 'model_driven_api', '~> 3.6'
The gem declares pagy ~> 9.0 as a runtime dependency and explicitly requires it at load time. No additional configuration is needed for pagination to work.
Include the engine concerns in your host models:
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
include ModelDrivenApiApplicationRecord
end
# app/models/user.rb
class User < ApplicationRecord
include ModelDrivenApiUser
end
# app/models/role.rb
class Role < ApplicationRecord
include ModelDrivenApiRole
end
Run migrations:
bundle exec rails db:migrate
Authentication
JWT (email/password)
POST /api/v2/authenticate
Content-Type: application/json
{ "auth": { "email": "admin@example.com", "password": "Change#1" } }
Response body: User JSON. Response header: Token: <jwt>.
Every subsequent authenticated request returns a fresh Token header (sliding expiration). Clients must read this header and store the new token on every response.
Token expiry is controlled by the SESSION_TIMEOUT_IN_MINUTES env var (default: 31 minutes).
When ALLOW_MULTISESSIONS=false, each login invalidates all previous tokens for the user (stored in the used_tokens table).
Bearer token usage (v2 and v3)
GET /api/v2/items
Authorization: Bearer <jwt>
GET /api/v3/items
Authorization: Bearer <jwt>
Accept: application/vnd.api+json
OAuth2 Authorization
OAuth2 is enabled when the relevant environment variables are set (see below). Two flows are supported:
Server-side OmniAuth callback (traditional)
Set redirect URI to http://yourdomain/auth/:provider/callback. The OmniAuth middleware redirects to /api/v2/auth/:provider/callback, which returns the user JSON and a Token header.
Frontend token exchange (POST /api/v2/auth/jwt)
For SPA frontends that obtain the OAuth token themselves:
POST /api/v2/auth/jwt
Content-Type: application/json
{ "provider": "google", "provider_token": "<google-access-token>" }
The backend verifies the token against the provider's userinfo endpoint and returns a JWT.
Register OAuth apps
- Go to Google Cloud Console → Credentials
- Create → OAuth 2.0 Client ID → Web Application
- Add Authorized JavaScript Origins: your frontend URL
- Note the Client ID
Microsoft Entra ID
- Go to portal.azure.com → Microsoft Entra ID → App registrations → New registration
- Set redirect URI type: SPA, value: your frontend URL
- Note: Application (client) ID, Directory (tenant) ID
- Under Authentication → Add platform: Single-page application
Environment variables
# Microsoft
ENTRA_CLIENT_ID=your-client-id
ENTRA_CLIENT_SECRET=your-client-secret
ENTRA_TENANT_ID=your-tenant-id
# Google
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
# JWT
SECRET_KEY_BASE=...
SESSION_TIMEOUT_IN_MINUTES=31
# Session mode: "false" enables token blacklisting
ALLOW_MULTISESSIONS=true
Frontend env vars (example for Vite):
VITE_GOOGLE_CLIENT_ID=your-client-id
VITE_AZURE_CLIENT_ID=your-client-id
VITE_AZURE_TENANT_ID=your-tenant-id
VITE_API_URL=http://yourdomain/api/v2/auth/google_oauth2/callback
API v2 — Plain JSON (Ransack)
CRUD endpoints
All ApplicationRecord subclasses get these endpoints automatically:
| Method | Path | Action |
|---|---|---|
| GET | /api/v2/:model |
Index (all records or paginated) |
| GET | /api/v2/:model/:id |
Show |
| POST | /api/v2/:model |
Create |
| PUT / PATCH | /api/v2/:model/:id |
Update |
| DELETE | /api/v2/:model/:id |
Destroy |
| PUT / PATCH | /api/v2/:model/:id/multi |
Bulk update (comma-separated ids) |
| DELETE | /api/v2/:model/:id/multi |
Bulk destroy |
| POST | /api/v2/:model/search |
Search (Ransack, same params as GET index) |
Search, filtering & pagination
Parameters work identically in query string (GET) and JSON body (POST search).
Pagination
| Param | Type | Effect |
|---|---|---|
page |
Integer | Page number |
per |
Integer | Records per page |
count |
any | Return { "count": N } instead of records |
Field selection (a or json_attrs)
{
"a": {
"only": ["id", "name"],
"methods": ["computed_field"],
"include": { "items": { "only": ["id", "code"] } }
}
}
Ransack filtering (q)
q[field_predicate]=value
Common predicates: _eq, _cont, _start, _end, _gt, _lt, _gteq, _lteq, _in, _present, _blank.
Sorting: q[s]=field_name asc.
Cross-association: q[user_email_end]=@example.com.
Examples
# Paginated index
GET /api/v2/users?page=2&per=10
# Filter + sort + field selection
GET /api/v2/orders?q[total_price_gt]=50&q[s]=created_at desc&a[only][]=id&a[only][]=total_price
# Count only
GET /api/v2/users?q[active_eq]=true&count=true
# Array filter
GET /api/v2/products?q[status_in][]=new&q[status_in][]=refurbished
POST equivalent (preferred for complex queries):
POST /api/v2/orders/search
Authorization: Bearer <jwt>
Content-Type: application/json
{
"q": { "total_price_gt": 50, "s": "created_at desc" },
"a": { "only": ["id", "total_price"] }
}
The response includes a Content-Range header: model_name start-end/total.
API v3 — JSON:API
All responses follow the JSON:API 1.0 specification. Send Accept: application/vnd.api+json and Content-Type: application/vnd.api+json on write requests.
CRUD endpoints
| Method | Path | Action | Response |
|---|---|---|---|
| GET | /api/v3/:model |
Index | { data: […], meta: { total: N } } |
| GET | /api/v3/:model/:id |
Show | { data: { id, type, attributes } } |
| POST | /api/v3/:model |
Create | { data: { … } } — 201 Created |
| PATCH | /api/v3/:model/:id |
Update | { data: { … } } — 200 OK |
| DELETE | /api/v3/:model/:id |
Destroy | 204 No Content |
Filtering
GET /api/v3/articles?filter[title]=Hello
GET /api/v3/articles?filter[status]=published&filter[author_id]=42
Field names are validated against the model's ransackable_attributes whitelist. Unknown fields are silently ignored.
Sorting
GET /api/v3/articles?sort=title # ascending
GET /api/v3/articles?sort=-created_at # descending
GET /api/v3/articles?sort=status,-title # multi-field
Pagination
GET /api/v3/articles?page[number]=2&page[size]=10
Response includes meta.total with the full count:
{
"data": [ … ],
"meta": { "total": 47 }
}
Sparse fieldsets
Return only a subset of attributes per type:
GET /api/v3/articles?fields[articles]=title,published_at
Multiple types can be narrowed in a single request when sideloading:
GET /api/v3/roles/1?include=users&fields[roles]=name&fields[users]=email
Sideloading (relationships)
Default sideloads are defined in the model's json_attrs[:include]. The client can override:
# Default sideloads from json_attrs[:include]
GET /api/v3/roles/1
# Explicit override — only sideload users
GET /api/v3/roles/1?include=users
# Suppress all sideloading
GET /api/v3/roles/1?include=
Sideloaded resources appear in a top-level included array per the JSON:API spec.
Create / update request body
POST /api/v3/articles
Content-Type: application/vnd.api+json
Authorization: Bearer <jwt>
{
"data": {
"type": "articles",
"attributes": {
"title": "My Article",
"body": "Content here"
}
}
}
JSON:API response example
{
"data": {
"id": "1",
"type": "articles",
"attributes": {
"title": "My Article",
"body": "Content here",
"published_at": "2026-06-08T10:00:00.000Z"
}
}
}
Attributes are driven by the model's json_attrs (minus :id, which is always the resource identifier). methods: entries become virtual attributes; include: entries produce relationship linkage and default sideloads.
Custom Actions (v2 and v3)
Custom actions are dispatched by Api::CustomActionDispatcher in both v2 and v3. Responses are plain JSON (not JSON:API envelopes) in both versions. The bearer token must be sent via the Authorization header — embedding tokens in the action name is no longer supported.
Pattern 1 — class method on the model
class MyModel < ApplicationRecord
# Called via: GET /api/v2/my_models?do=report
# GET /api/v3/my_models?do=report
def self.custom_action_report(params)
[{ total: count, params: params }, 200]
end
end
Pattern 2 — NonCrudEndpoints subclass (with OpenAPI docs)
Place in app/models/endpoints/my_model.rb:
class Endpoints::MyModel < NonCrudEndpoints
self.desc 'MyModel', :report, {
get: {
summary: "Monthly Report",
tags: ["MyModel"],
parameters: [
{ name: "month", in: "query", required: true, schema: { type: "integer" } }
],
responses: {
200 => { description: "Report data", content: { "application/json": { schema: { type: "object" } } } }
}
}
}
def report(params)
[{ month: params[:month], data: [] }, 200]
end
end
Routes for pattern 2 (same shape in v2 and v3):
GET /api/v2/my_models/custom_action/report
GET /api/v3/my_models/custom_action/report
GET /api/v2/my_models/custom_action/report/:id
GET /api/v3/my_models/custom_action/report/:id
POST / PUT / PATCH / DELETE also available in both versions
JSON Serialisation DSL (json_attrs)
Each model can declare self.json_attrs to control the API response shape. In v2 this mirrors the Rails as_json API; in v3 the [:only] list drives the generated JSON:API serializer.
class MyModel < ApplicationRecord
cattr_accessor :json_attrs
self.json_attrs = {
only: [:id, :name, :status], # attribute whitelist
except: [:internal_notes], # attribute blacklist (used when only: is absent)
methods: [:computed_value], # virtual attributes (callable on instance)
include: {
category: { only: [:id, :name] },
tags: { only: [:id, :label] }
}
}
end
v2 behaviour: only/except/methods/include are passed through Rails as_json on every response. Clients may override per-request via the a or json_attrs query/body parameter.
v3 behaviour:
only:/except:— drive the generated JSON:API serializer's attribute list.methods:— each entry becomes a virtual attribute usingobject.send(method)(private methods are supported, consistent with Railsas_json).include:— each entry declares a relationship on the serializer and becomes a default sideload. The client can suppress or replace defaults with?include=(empty to suppress, comma-separated list to override).
When composing json_attrs across multiple concerns, use ModelDrivenApi.smart_merge to deep-merge without losing fields set by other concerns:
self.json_attrs = ModelDrivenApi.smart_merge((json_attrs || {}), { only: [:id, :name] })
Raw SQL endpoint
Executes read-only SELECT queries. Authentication required. Only SELECT (and WITH … SELECT) statements are allowed; DDL and DML are rejected with HTTP 400.
v2 — requires result key
POST /api/v2/raw/sql
Authorization: Bearer <jwt>
Content-Type: application/json
{
"query": "SELECT json_agg(u) AS result FROM users u WHERE u.admin = true"
}
The query must return a result column (use json_agg or jsonb_agg).
v3 — plain JSON array
GET /api/v3/raw/sql?query=SELECT+id,title+FROM+articles+LIMIT+10
Authorization: Bearer <jwt>
POST /api/v3/raw/sql
Authorization: Bearer <jwt>
Content-Type: application/json
{ "query": "SELECT id, title FROM articles ORDER BY created_at DESC LIMIT 10" }
Returns rows directly as a JSON array — no result key, no JSON:API envelope. This is a deliberate exception to JSON:API compliance for the SQL escape hatch.
Info endpoints (v2 and v3)
All info endpoints are available under both /api/v2/info/ and /api/v3/info/. v3 clients can use a single base URL for auth, CRUD, and info.
| Endpoint | Auth | Description |
|---|---|---|
GET /info/version |
No | App version string |
GET /info/heartbeat |
Yes | Renews token, returns current user |
GET /info/ntp |
Yes | Server UTC time (client clock sync) |
GET /info/roles |
Yes | All roles |
GET /info/schema |
Yes | DB schema for models the user can read |
GET /info/dsl |
Yes | json_attrs DSL for each model |
GET /info/translations |
Yes | Full i18n tree (?locale=en) |
GET /info/settings |
Yes | All ThecoreSettings::Setting values |
GET /info/swagger |
No | OpenAPI 3.0 spec (alias: /info/openapi) |
The v2 and v3 swagger specs are different — v2 documents plain JSON CRUD + Ransack + search + bulk ops; v3 documents JSON:API envelopes + filter/sort/page params + 204 on delete.
ActiveStorage file uploads (v2)
For models with has_many_attached :assets, use multipart/form-data — do not use JSON:
const formData = new FormData();
formData.append('product[title]', title);
files.forEach(file => formData.append('product[assets][]', file));
// Do NOT set Content-Type manually — the browser sets the boundary
fetch('/api/v2/products', { method: 'POST', body: formData });
To delete attachments, pass the ActiveStorage::Attachment IDs via a virtual attribute:
idsToRemove.forEach(id => formData.append('product[remove_assets][]', id));
fetch(`/api/v2/products/${id}`, { method: 'PATCH', body: formData });
License
MIT