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 });
Web Push (VAPID) from a React client
This section is the complete integration guide for React apps that want to receive browser push notifications from a Thecore backend. The server-side setup (models, service, ActionCable channel) is documented in the thecore_backend_commons README.
Prerequisites
- Run
rails db:seedon the backend — this generates the VAPID key pair automatically. - Set
vapid.contact_emailin RailsAdmin → Settings (e.g.admin@yourapp.com). - Your site must be served over HTTPS (required by the Push API in all browsers).
localhostis exempt for development.
Endpoint reference
All endpoints live under /api/v2/push_subscribers/custom_action/.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
vapid_public_key |
No | Returns the VAPID public key for PushManager.subscribe |
POST |
subscribe |
Yes (JWT) | Registers or updates a push subscription for the current user |
POST |
send_push |
Yes (JWT) | Creates a PushMessage and dispatches it to an active subscriber |
POST |
acknowledge |
Yes (JWT) | Marks a message as received and/or read |
Step 1 — Register a service worker
Create public/sw.js in your React app:
// public/sw.js
self.addEventListener('push', event => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'Notification', {
body: data.body,
icon: data.icon ?? '/favicon.ico',
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const url = event.notification.data?.url;
if (url) {
event.waitUntil(clients.openWindow(url));
}
});
Step 2 — Subscribe to push notifications
// src/usePushSubscription.js
const API_BASE = process.env.REACT_APP_API_URL ?? '/api/v2';
// Convert a base64url string to a Uint8Array (required by PushManager)
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
// Convert a PushSubscription key to a base64url string (required by the backend)
function keyToBase64(subscription, key) {
return btoa(String.fromCharCode(...new Uint8Array(subscription.getKey(key))));
}
export async function subscribeToPush(jwtToken) {
// 1. Register service worker
const registration = await navigator.serviceWorker.register('/sw.js');
// 2. Fetch the VAPID public key (no auth needed)
const res = await fetch(`${API_BASE}/push_subscribers/custom_action/vapid_public_key`);
const { vapid_public_key } = await res.json();
// 3. Subscribe via the Push API
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
});
// 4. Register the subscription with the backend
const registerRes = await fetch(
`${API_BASE}/push_subscribers/custom_action/subscribe`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
},
body: JSON.stringify({
endpoint: subscription.endpoint,
p256dh: keyToBase64(subscription, 'p256dh'),
auth: keyToBase64(subscription, 'auth'),
user_agent: navigator.userAgent,
}),
}
);
const subscriber = await registerRes.json();
// subscriber.id is the push_subscriber_id to use with ActionCable and send_push
return subscriber;
}
Call this after the user logs in (a JWT is required for subscribe):
import { subscribeToPush } from './usePushSubscription';
const subscriber = await subscribeToPush(jwtToken);
localStorage.setItem('push_subscriber_id', subscriber.id);
Step 3 — Listen via ActionCable
Install the ActionCable client if you haven't already:
npm install @rails/actioncable
# or
yarn add @rails/actioncable
// src/usePushChannel.js
import { createConsumer } from '@rails/actioncable';
const WS_URL = process.env.REACT_APP_WS_URL ?? 'ws://localhost:3000/cable';
export function connectPushChannel(jwtToken, subscriberId, onMessage) {
// The JWT token is passed as a query parameter so the ActionCable
// connection.rb can authenticate the WebSocket handshake.
const consumer = createConsumer(`${WS_URL}?token=${jwtToken}`);
const subscription = consumer.subscriptions.create(
{ channel: 'PushNotificationChannel', subscriber_id: subscriberId },
{
received(data) {
// data is a PushMessage serialised as JSON:
// { id, title, body, url, icon, sent_at, received_at, read_at }
onMessage(data);
},
connected() {
console.log('[PushNotificationChannel] connected');
},
disconnected() {
console.log('[PushNotificationChannel] disconnected');
},
}
);
// Return a cleanup function
return () => {
subscription.unsubscribe();
consumer.disconnect();
};
}
Use it in a React component or hook:
import { useEffect } from 'react';
import { connectPushChannel } from './usePushChannel';
function App() {
const jwtToken = localStorage.getItem('token');
const subscriberId = localStorage.getItem('push_subscriber_id');
useEffect(() => {
if (!jwtToken || !subscriberId) return;
const disconnect = connectPushChannel(jwtToken, subscriberId, message => {
console.log('New push message via ActionCable:', message);
// Optionally acknowledge receipt immediately
acknowledgeMessage(jwtToken, message.id, { received: true });
});
return disconnect; // cleanup on unmount
}, [jwtToken, subscriberId]);
}
Tip: use
user_idinstead ofsubscriber_idif you want to receive messages across all active subscriptions for the current user (e.g. multiple tabs):{ channel: 'PushNotificationChannel', user_id: currentUserId }
Step 4 — Acknowledge receipt and read
// src/pushApi.js
const API_BASE = process.env.REACT_APP_API_URL ?? '/api/v2';
export async function acknowledgeMessage(jwtToken, messageId, { received = false, read = false } = {}) {
const res = await fetch(
`${API_BASE}/push_subscribers/custom_action/acknowledge`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
},
body: JSON.stringify({
push_message_id: messageId,
received,
read,
}),
}
);
return res.json();
// Response: { id, title, body, url, icon, sent_at, received_at, read_at, ... }
}
Call received: true as soon as the message arrives via ActionCable. Call read: true when the user opens or dismisses it. Fields are set only once — a second call with the same flag is a no-op (idempotent).
Step 5 — Send a push from the backend (optional)
Normally the backend triggers pushes from jobs or model callbacks. If you need to trigger a push from a privileged frontend (e.g. an admin panel), use send_push:
export async function sendPush(jwtToken, { subscriberId, title, body, url, icon }) {
const res = await fetch(
`${API_BASE}/push_subscribers/custom_action/send_push`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
},
body: JSON.stringify({
push_subscriber_id: subscriberId,
title,
body,
url, // optional
icon, // optional
}),
}
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error ?? `HTTP ${res.status}`);
}
return res.json(); // PushMessage record
}
Full flow summary
React app Thecore backend
│ │
│── GET vapid_public_key ──────────►│ (no auth)
│◄── { vapid_public_key: "..." } ───│
│ │
│ navigator.serviceWorker.register('/sw.js')
│ registration.pushManager.subscribe({ applicationServerKey })
│ │
│── POST subscribe ────────────────►│ creates/updates PushSubscriber
│◄── { id: 42, endpoint, ... } ─────│
│ │
│ createConsumer(wsUrl?token=jwt) │
│── WS upgrade ────────────────────►│ PushNotificationChannel#subscribed
│◄── stream: push_notifications_subscriber_42
│ │
│ [backend dispatches push] │
│◄── Web Push payload (sw.js) ──────│ Webpush.payload_send via VAPID
│ showNotification(title, body) │
│ │
│◄── ActionCable data ──────────────│ PushNotificationChannel.broadcast_to
│ onMessage(data) │
│ │
│── POST acknowledge (received) ───►│ message.received_at = now
│── POST acknowledge (read) ────────►│ message.read_at = now
Handling subscription expiry
Push service endpoints expire (the push provider returns HTTP 410). The backend automatically calls subscriber.expire! when this happens, but the client needs to re-subscribe on the next boot:
export async function ensureSubscription(jwtToken) {
// Re-subscribe unconditionally — subscribe_for upserts on endpoint,
// so re-registering the same browser is always safe and clears expired_at.
const sub = await subscribeToPush(jwtToken);
localStorage.setItem('push_subscriber_id', sub.id);
return sub;
}
Permissions check
Before calling subscribeToPush, check that the browser supports push and the user has granted permission:
export async function requestPushPermission() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Web Push not supported in this browser');
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
License
MIT