diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 16dff2c..f46f06c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,40 @@ jobs:
DB_USER: user
DB_PASSWORD: password
run: bundle exec rake
+ OpenAPI:
+ runs-on: ubuntu-22.04
+ env:
+ ALCHEMY_BRANCH: main
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.4"
+ bundler-cache: true
+ - name: Restore apt cache
+ id: apt-cache
+ uses: actions/cache@v4
+ with:
+ path: /home/runner/apt/cache
+ key: apt-sqlite-
+ - name: Install SQLite headers
+ run: |
+ sudo mkdir -p /home/runner/apt/cache
+ sudo apt-get update -qq
+ sudo apt-get install -qq --fix-missing libsqlite3-dev -o dir::cache::archives="/home/runner/apt/cache"
+ sudo chown -R runner /home/runner/apt/cache
+ - name: Regenerate OpenAPI spec
+ env:
+ RAILS_ENV: test
+ OPENAPI: "1"
+ run: bundle exec rake
+ - name: Check for OpenAPI spec drift
+ run: |
+ if ! git diff --ignore-all-space --exit-code docs/openapi.yml; then
+ echo "::error::docs/openapi.yml is out of date. Run 'OPENAPI=1 bundle exec rspec' locally and commit the result."
+ exit 1
+ fi
Vitest:
runs-on: ubuntu-24.04
env:
diff --git a/Gemfile b/Gemfile
index c8bcb7b..3b53079 100644
--- a/Gemfile
+++ b/Gemfile
@@ -26,3 +26,7 @@ gem "standard", "~> 1.25", require: false
gem "pry-byebug"
gem "propshaft", "~> 1.3"
+
+gem "rspec-openapi", require: false
+
+gem "puma", "~> 7.2"
diff --git a/README.md b/README.md
index da0f0f8..5db9302 100644
--- a/README.md
+++ b/README.md
@@ -97,9 +97,56 @@ Alchemy::JsonApi.key_transform = :camel_lower
It defaults to `:underscore`.
+## OpenAPI Documentation
+
+The API is documented with an OpenAPI 3.0 spec at `docs/openapi.yml`. When the
+engine is mounted, the spec is served as JSON at:
+
+```
+GET /jsonapi/openapi.json
+```
+
+Point Swagger UI, Redoc, or any OpenAPI client generator at this URL.
+
+### Regenerating the spec
+
+The spec is auto-generated from request specs using
+[rspec-openapi](https://github.com/exoego/rspec-openapi). To update it after
+changing endpoints or serializers:
+
+```bash
+OPENAPI=1 bundle exec rspec
+```
+
+This merges actual API responses into `docs/openapi.yml`. Hand-written component
+schemas are preserved. Review the diff and commit the result.
+
+> [!NOTE]
+> CI will fail if `docs/openapi.yml` is out of date. Always regenerate and
+> commit the spec when changing API endpoints or serializers.
+
## Contributing
-Contribution directions go here.
+1. Fork the repo and create your branch from `main`.
+2. Install dependencies:
+ ```bash
+ bundle install
+ ```
+3. Run the test suite:
+ ```bash
+ bundle exec rake
+ ```
+4. If you changed any API endpoints or serializers, regenerate the OpenAPI spec:
+ ```bash
+ OPENAPI=1 bundle exec rspec
+ ```
+ Review the diff in `docs/openapi.yml` and include it in your commit. CI will
+ reject PRs where the spec is out of date.
+5. Ensure linting passes:
+ ```bash
+ bundle exec standardrb
+ ```
+6. Open a pull request.
## License
diff --git a/alchemy-json_api.gemspec b/alchemy-json_api.gemspec
index dd98a8c..7c918c0 100644
--- a/alchemy-json_api.gemspec
+++ b/alchemy-json_api.gemspec
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
spec.description = "A JSONAPI compliant API for AlchemyCMS"
spec.license = "BSD-3-Clause"
- spec.files = Dir["{app,config,db,lib}/**/*", "LICENSE", "Rakefile", "README.md"]
+ spec.files = Dir["{app,config,db,docs,lib}/**/*", "LICENSE", "Rakefile", "README.md"]
spec.add_dependency "alchemy_cms", [">= 8.2.0.a", "< 9"]
spec.add_dependency "jsonapi.rb", [">= 1.6.0", "< 2.2"]
diff --git a/app/controllers/alchemy/json_api/openapi_controller.rb b/app/controllers/alchemy/json_api/openapi_controller.rb
new file mode 100644
index 0000000..0f1c524
--- /dev/null
+++ b/app/controllers/alchemy/json_api/openapi_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require "yaml"
+require "json"
+
+module Alchemy
+ module JsonApi
+ class OpenapiController < ::ApplicationController
+ def show
+ render json: spec
+ end
+
+ def docs
+ @spec_url = alchemy_json_api.openapi_path(format: :json)
+ render layout: false
+ end
+
+ private
+
+ def spec
+ @spec ||= JSON.generate(
+ YAML.safe_load_file(
+ Alchemy::JsonApi::Engine.root.join("docs", "openapi.yml"),
+ permitted_classes: [Date, Time]
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/app/views/alchemy/json_api/openapi/docs.html.erb b/app/views/alchemy/json_api/openapi/docs.html.erb
new file mode 100644
index 0000000..5f56917
--- /dev/null
+++ b/app/views/alchemy/json_api/openapi/docs.html.erb
@@ -0,0 +1,22 @@
+
+
+
+
+ Alchemy JSON:API Documentation
+
+
+
+
+
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 611cd58..0a75ffa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
Alchemy::JsonApi::Engine.routes.draw do
+ get "openapi", to: "openapi#show", defaults: {format: :json}, as: :openapi
+ get "docs", to: "openapi#docs", constraints: ->(_r) { Rails.env.development? }
+
resources :pages, only: [:index]
get "pages/*path" => "pages#show", :as => :page
resources :layout_pages, only: [:index]
diff --git a/docs/bruno/.gitignore b/docs/bruno/.gitignore
new file mode 100644
index 0000000..e19311f
--- /dev/null
+++ b/docs/bruno/.gitignore
@@ -0,0 +1,9 @@
+# Secrets
+.env*
+
+# Dependencies
+node_modules
+
+# OS files
+.DS_Store
+Thumbs.db
\ No newline at end of file
diff --git a/docs/bruno/opencollection.yml b/docs/bruno/opencollection.yml
new file mode 100644
index 0000000..53ad436
--- /dev/null
+++ b/docs/bruno/opencollection.yml
@@ -0,0 +1,10 @@
+opencollection: 1.0.0
+
+info:
+ name: Alchemy JSON:API
+bundled: false
+extensions:
+ bruno:
+ ignore:
+ - node_modules
+ - .git
diff --git a/docs/openapi.yml b/docs/openapi.yml
new file mode 100644
index 0000000..b17fd74
--- /dev/null
+++ b/docs/openapi.yml
@@ -0,0 +1,1222 @@
+# This file is auto-generated by rspec-openapi.
+# Run `OPENAPI=1 bundle exec rspec` to regenerate.
+---
+openapi: 3.0.3
+info:
+ title: Alchemy CMS JSON API
+ description: |-
+ A [JSON:API](https://jsonapi.org/) compliant API for [AlchemyCMS](https://alchemy-cms.com).
+
+ All responses follow the JSON:API specification. Use the `include` query parameter to
+ sideload related resources, `filter` for Ransack-based filtering, and `page` for pagination.
+
+ ## Authentication
+
+ Public endpoints serve published content without authentication. Admin endpoints require
+ an authenticated user with `edit_content` permission on `Alchemy::Page`.
+
+ ## Caching
+
+ Responses include HTTP caching headers (ETag, Cache-Control). Page endpoints default to
+ a max-age of 600 seconds (configurable via `ALCHEMY_JSON_API_CACHE_DURATION` env var).
+ Node endpoints default to 3 hours. Restricted pages are never publicly cached.
+ version: 8.2.0.a
+ license:
+ name: BSD-3-Clause
+ url: https://opensource.org/licenses/BSD-3-Clause
+ contact:
+ name: AlchemyCMS
+ url: https://www.alchemy-cms.com/
+servers:
+- url: "/jsonapi"
+ description: Mounted engine path (default)
+tags:
+- name: Pages
+ description: Content pages
+- name: Layout Pages
+ description: Layout pages (global partials like headers, footers)
+- name: Nodes
+ description: Navigation menu nodes
+- name: Admin Pages
+ description: Draft page preview (requires authentication)
+- name: Admin Layout Pages
+ description: Draft layout page preview (requires authentication)
+paths:
+ "/jsonapi/admin/layout_pages":
+ get:
+ summary: index
+ tags: []
+ responses:
+ '200':
+ description: returns paginated result
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":[{"id":"2","type":"page","attributes":{"name":"A Public
+ Page 11","urlname":"a-public-page-11","url_path":"/a-public-page-11","page_layout":"footer","language_code":"en","created_at":"2026-03-21T22:53:48.140Z","updated_at":"2026-03-21T22:53:48.141Z","restricted":false,"legacy_urls":[],"title":"A
+ Public Page 11","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}}],"meta":{"pagination":{"current":2,"first":1,"prev":1,"next":3,"last":3,"records":3},"total":3},"links":{"self":"http://www.example.com/jsonapi/admin/layout_pages?page%5Bnumber%5D=2\u0026page%5Bsize%5D=1","current":"http://www.example.com/jsonapi/admin/layout_pages?page[number]=2\u0026page[size]=1","first":"http://www.example.com/jsonapi/admin/layout_pages?page[number]=1\u0026page[size]=1","prev":"http://www.example.com/jsonapi/admin/layout_pages?page[number]=1\u0026page[size]=1","next":"http://www.example.com/jsonapi/admin/layout_pages?page[number]=3\u0026page[size]=1","last":"http://www.example.com/jsonapi/admin/layout_pages?page[number]=3\u0026page[size]=1"}}'
+ '401':
+ description: returns 401
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"errors":[{"status":"401","source":null,"title":"Unauthorized","detail":null,"code":null}]}'
+ parameters:
+ - name: page[number]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 2
+ - name: page[size]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 1
+ "/jsonapi/admin/layout_pages/*path":
+ get:
+ summary: show
+ tags: []
+ parameters:
+ - name: path
+ in: path
+ required: true
+ schema:
+ oneOf:
+ - type: integer
+ - type: string
+ example: a-page-9
+ responses:
+ '200':
+ description: gets a valid JSON:API document
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":{"id":"1","type":"page","attributes":{"name":"A Page
+ 9","urlname":"a-page-9","url_path":"/a-page-9","page_layout":"footer","language_code":"en","created_at":"2026-03-21T22:53:48.302Z","updated_at":"2026-03-21T22:53:48.303Z","restricted":false,"legacy_urls":[],"title":"A
+ Page 9","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}},"meta":{"total":1},"links":{"self":"http://www.example.com/jsonapi/admin/layout_pages/a-page-9"}}'
+ '304':
+ description: returns not modified
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ '401':
+ description: returns 401
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"errors":[{"status":"401","source":null,"title":"Unauthorized","detail":null,"code":null}]}'
+ "/jsonapi/admin/pages/*path":
+ get:
+ summary: show
+ tags: []
+ parameters:
+ - name: path
+ in: path
+ required: true
+ schema:
+ oneOf:
+ - type: integer
+ - type: string
+ example: a-page-15
+ responses:
+ '200':
+ description: gets a valid JSON:API document
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":{"id":"2","type":"page","attributes":{"name":"A Page
+ 15","urlname":"a-page-15","url_path":"/a-page-15","page_layout":"standard","language_code":"en","created_at":"2026-03-21T22:53:48.489Z","updated_at":"2026-03-21T22:53:48.491Z","restricted":false,"legacy_urls":[],"title":"A
+ Page 15","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[{"id":"1","type":"page"}]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}},"meta":{"total":2},"links":{"self":"http://www.example.com/jsonapi/admin/pages/a-page-15"}}'
+ '304':
+ description: returns not modified
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ '401':
+ description: returns 401
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"errors":[{"status":"401","source":null,"title":"Unauthorized","detail":null,"code":null}]}'
+ "/jsonapi/layout_pages":
+ get:
+ summary: index
+ tags: []
+ responses:
+ '200':
+ description: returns paginated result
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":[{"id":"2","type":"page","attributes":{"name":"A Public
+ Page 22","urlname":"a-public-page-22","url_path":"/a-public-page-22","page_layout":"footer","language_code":"en","created_at":"2026-03-21T22:53:48.708Z","updated_at":"2026-03-21T22:53:48.709Z","restricted":false,"legacy_urls":[],"title":"A
+ Public Page 22","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}}],"meta":{"pagination":{"current":2,"first":1,"prev":1,"next":3,"last":3,"records":3},"total":3},"links":{"self":"http://www.example.com/jsonapi/layout_pages?page%5Bnumber%5D=2\u0026page%5Bsize%5D=1","current":"http://www.example.com/jsonapi/layout_pages?page[number]=2\u0026page[size]=1","first":"http://www.example.com/jsonapi/layout_pages?page[number]=1\u0026page[size]=1","prev":"http://www.example.com/jsonapi/layout_pages?page[number]=1\u0026page[size]=1","next":"http://www.example.com/jsonapi/layout_pages?page[number]=3\u0026page[size]=1","last":"http://www.example.com/jsonapi/layout_pages?page[number]=3\u0026page[size]=1"}}'
+ parameters:
+ - name: page[number]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 2
+ - name: page[size]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 1
+ "/jsonapi/layout_pages/*path":
+ get:
+ summary: show
+ tags: []
+ parameters:
+ - name: path
+ in: path
+ required: true
+ schema:
+ oneOf:
+ - type: integer
+ - type: string
+ example: a-page-17
+ responses:
+ '200':
+ description: finds the page
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":{"id":"1","type":"page","attributes":{"name":"A Public
+ Page 15","urlname":"a-public-page-15","url_path":"/a-public-page-15","page_layout":"footer","language_code":"en","created_at":"2026-03-21T22:53:48.568Z","updated_at":"2026-03-21T22:53:48.569Z","restricted":false,"legacy_urls":[],"title":"A
+ Public Page 15","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}},"meta":{"total":1},"links":{"self":"http://www.example.com/jsonapi/layout_pages/a-public-page-15"}}'
+ '404':
+ description: does not find the page
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"errors":[{"status":"404","source":null,"title":"Not Found","detail":null,"code":null}]}'
+ "/jsonapi/nodes":
+ get:
+ summary: index
+ tags: []
+ responses:
+ '200':
+ description: returns paginated result
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ Last-Modified:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":[{"id":"2","type":"node","attributes":{"name":"A Node","link_url":null,"link_title":null,"link_nofollow":false},"relationships":{"parent":{"data":null},"children":{"data":[]}}}],"meta":{"pagination":{"current":2,"first":1,"prev":1,"next":3,"last":4,"records":4},"total":4},"links":{"self":"http://www.example.com/jsonapi/nodes?page%5Bnumber%5D=2\u0026page%5Bsize%5D=1","current":"http://www.example.com/jsonapi/nodes?page[number]=2\u0026page[size]=1","first":"http://www.example.com/jsonapi/nodes?page[number]=1\u0026page[size]=1","prev":"http://www.example.com/jsonapi/nodes?page[number]=1\u0026page[size]=1","next":"http://www.example.com/jsonapi/nodes?page[number]=3\u0026page[size]=1","last":"http://www.example.com/jsonapi/nodes?page[number]=4\u0026page[size]=1"}}'
+ '304':
+ description: returns not modified
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ Last-Modified:
+ schema:
+ type: string
+ parameters:
+ - name: include
+ in: query
+ required: false
+ schema:
+ type: string
+ example: page.all_elements.ingredients
+ - name: page[number]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 2
+ - name: page[size]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 1
+ "/jsonapi/pages":
+ get:
+ summary: index
+ tags: []
+ parameters:
+ - name: fields
+ in: query
+ required: false
+ schema:
+ type: string
+ example: author
+ - name: filter[name_eq]
+ in: query
+ required: false
+ schema:
+ type: string
+ example: News
+ - name: filter[page_layout_eq]
+ in: query
+ required: false
+ schema:
+ type: string
+ example: news
+ - name: include
+ in: query
+ required: false
+ schema:
+ type: string
+ example: all_elements.ingredients
+ - name: page[number]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 2
+ - name: page[size]
+ in: query
+ required: false
+ schema:
+ type: integer
+ example: 1
+ - name: sort
+ in: query
+ required: false
+ schema:
+ type: string
+ example: "-id"
+ responses:
+ '200':
+ description: returns paginated result
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":[{"id":"2","type":"page","attributes":{"name":"A Public
+ Page 73","urlname":"a-public-page-73","url_path":"/a-public-page-73","page_layout":"standard","language_code":"en","created_at":"2024-04-27T00:00:00.000Z","updated_at":"2024-04-27T00:00:00.000Z","restricted":false,"legacy_urls":[],"title":"A
+ Public Page 73","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[{"id":"1","type":"page"}]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}}],"meta":{"pagination":{"current":2,"first":1,"prev":1,"next":3,"last":4,"records":4},"total":4},"links":{"self":"http://www.example.com/jsonapi/pages?page%5Bnumber%5D=2\u0026page%5Bsize%5D=1","current":"http://www.example.com/jsonapi/pages?page[number]=2\u0026page[size]=1","first":"http://www.example.com/jsonapi/pages?page[number]=1\u0026page[size]=1","prev":"http://www.example.com/jsonapi/pages?page[number]=1\u0026page[size]=1","next":"http://www.example.com/jsonapi/pages?page[number]=3\u0026page[size]=1","last":"http://www.example.com/jsonapi/pages?page[number]=4\u0026page[size]=1"}}'
+ '304':
+ description: returns not modified
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ "/jsonapi/pages/*path":
+ get:
+ summary: show
+ tags: []
+ parameters:
+ - name: include
+ in: query
+ required: false
+ schema:
+ type: string
+ example: all_elements.ingredients
+ - name: path
+ in: path
+ required: true
+ schema:
+ oneOf:
+ - type: integer
+ - type: string
+ example: a-page-22
+ responses:
+ '200':
+ description: finds the page
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"data":{"id":"2","type":"page","attributes":{"name":"A Public
+ Page 38","urlname":"page","url_path":"/page","page_layout":"standard","language_code":"en","created_at":"2024-04-27T00:00:00.000Z","updated_at":"2024-04-27T00:00:00.000Z","restricted":false,"legacy_urls":[],"title":"A
+ Public Page 38","meta_keywords":null,"meta_description":null},"relationships":{"language":{"data":{"id":"1","type":"language"}},"ancestors":{"data":[{"id":"1","type":"page"}]},"all_elements":{"data":[]},"elements":{"data":[]},"fixed_elements":{"data":[]}}},"meta":{"total":2},"links":{"self":"http://www.example.com/jsonapi/pages/page"}}'
+ '304':
+ description: returns not modified
+ headers:
+ ETag:
+ schema:
+ type: string
+ Cache-Control:
+ schema:
+ type: string
+ '404':
+ description: does not find the page
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ content:
+ application/vnd.api+json:
+ schema:
+ type: string
+ example: '{"errors":[{"status":"404","source":null,"title":"Not Found","detail":null,"code":null}]}'
+components:
+ securitySchemes:
+ cookieAuth:
+ type: apiKey
+ in: cookie
+ name: _alchemy_session
+ description: Alchemy CMS session cookie (required for admin endpoints)
+ parameters:
+ IncludeParam:
+ name: include
+ in: query
+ required: false
+ description: |
+ Comma-separated list of related resources to include (JSON:API spec).
+ For pages: `language`, `all_elements`, `all_elements.ingredients`,
+ `all_elements.nested_elements`, `elements`, `fixed_elements`, `ancestors`.
+ For nodes: `children`, `parent`, `page`, `children.children`.
+ schema:
+ type: string
+ example: all_elements,all_elements.ingredients,language
+ FieldsParam:
+ name: fields
+ in: query
+ required: false
+ style: deepObject
+ explode: true
+ description: |
+ Sparse fieldsets (JSON:API spec). Limit which fields are returned per resource type.
+ Example: `fields[page]=name,urlname,url_path`
+ schema:
+ type: object
+ additionalProperties:
+ type: string
+ SortParam:
+ name: sort
+ in: query
+ required: false
+ description: |
+ Comma-separated list of sort fields (JSON:API spec). Prefix with `-` for descending.
+ Example: `-updated_at,name`
+ schema:
+ type: string
+ PageNumberParam:
+ name: page[number]
+ in: query
+ required: false
+ description: Page number for pagination (1-based)
+ schema:
+ type: integer
+ minimum: 1
+ default: 1
+ PageSizeParam:
+ name: page[size]
+ in: query
+ required: false
+ description: Number of records per page
+ schema:
+ type: integer
+ minimum: 1
+ headers:
+ ETag:
+ description: Entity tag for conditional requests
+ schema:
+ type: string
+ Cache-Control:
+ description: Caching directives
+ schema:
+ type: string
+ example: max-age=600, public, must-revalidate
+ responses:
+ NotFound:
+ description: Resource not found
+ content:
+ application/vnd.api+json:
+ schema:
+ "$ref": "#/components/schemas/JsonApiErrors"
+ Forbidden:
+ description: Insufficient permissions
+ content:
+ application/vnd.api+json:
+ schema:
+ "$ref": "#/components/schemas/JsonApiErrors"
+ schemas:
+ JsonApiErrors:
+ type: object
+ properties:
+ errors:
+ type: array
+ items:
+ type: object
+ properties:
+ status:
+ type: string
+ title:
+ type: string
+ detail:
+ type: string
+ PaginationMeta:
+ type: object
+ properties:
+ meta:
+ type: object
+ properties:
+ pagination:
+ type: object
+ properties:
+ current:
+ type: integer
+ first:
+ type: integer
+ prev:
+ type: integer
+ nullable: true
+ next:
+ type: integer
+ nullable: true
+ last:
+ type: integer
+ total:
+ type: integer
+ PageResource:
+ type: object
+ required:
+ - id
+ - type
+ properties:
+ id:
+ type: string
+ type:
+ type: string
+ enum:
+ - page
+ attributes:
+ "$ref": "#/components/schemas/PageAttributes"
+ relationships:
+ "$ref": "#/components/schemas/PageRelationships"
+ PageAttributes:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Internal page name
+ urlname:
+ type: string
+ description: URL slug for this page
+ url_path:
+ type: string
+ description: Full URL path (including language prefix)
+ page_layout:
+ type: string
+ description: Page layout name (maps to Alchemy page layout definition)
+ title:
+ type: string
+ nullable: true
+ description: HTML title (from page version)
+ meta_keywords:
+ type: string
+ nullable: true
+ description: Meta keywords (from page version)
+ meta_description:
+ type: string
+ nullable: true
+ description: Meta description (from page version)
+ language_code:
+ type: string
+ description: Two-letter language code
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ restricted:
+ type: boolean
+ description: Whether this page requires authentication to view
+ legacy_urls:
+ type: array
+ items:
+ type: string
+ description: Previous URL names that redirect to this page
+ deprecated:
+ type: boolean
+ description: Whether the page layout is marked as deprecated
+ PageRelationships:
+ type: object
+ properties:
+ language:
+ type: object
+ properties:
+ data:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ ancestors:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ all_elements:
+ type: object
+ description: All public elements (top-level, fixed, and nested). Use for
+ eager loading via `include`.
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ elements:
+ type: object
+ description: Top-level, non-fixed public elements only
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ fixed_elements:
+ type: object
+ description: Top-level, fixed public elements only
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ ElementResource:
+ type: object
+ required:
+ - id
+ - type
+ properties:
+ id:
+ type: string
+ type:
+ type: string
+ enum:
+ - element
+ attributes:
+ "$ref": "#/components/schemas/ElementAttributes"
+ relationships:
+ "$ref": "#/components/schemas/ElementRelationships"
+ ElementAttributes:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Element definition name
+ fixed:
+ type: boolean
+ description: Whether this element is fixed to the page layout
+ position:
+ type: integer
+ description: Sort position within its container
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ deprecated:
+ type: boolean
+ description: Whether the element definition is marked as deprecated
+ ElementRelationships:
+ type: object
+ properties:
+ ingredients:
+ type: object
+ description: |
+ Polymorphic ingredients. Each ingredient's `type` field indicates the specific kind
+ (e.g. `ingredient_text`, `ingredient_picture`, `ingredient_richtext`).
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ nested_elements:
+ type: object
+ description: Child elements nested within this element
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ LanguageResource:
+ type: object
+ required:
+ - id
+ - type
+ properties:
+ id:
+ type: string
+ type:
+ type: string
+ enum:
+ - language
+ attributes:
+ "$ref": "#/components/schemas/LanguageAttributes"
+ relationships:
+ "$ref": "#/components/schemas/LanguageRelationships"
+ LanguageAttributes:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Human-readable language name
+ language_code:
+ type: string
+ description: ISO 639-1 language code
+ country_code:
+ type: string
+ description: ISO 3166-1 country code
+ locale:
+ type: string
+ description: Full locale string (e.g. `en_US`)
+ LanguageRelationships:
+ type: object
+ properties:
+ menu_items:
+ type: object
+ description: All navigation nodes for this language
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ menus:
+ type: object
+ description: Root-level navigation nodes (menus) for this language
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ pages:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ root_page:
+ type: object
+ properties:
+ data:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ NodeResource:
+ type: object
+ required:
+ - id
+ - type
+ properties:
+ id:
+ type: string
+ type:
+ type: string
+ enum:
+ - node
+ attributes:
+ "$ref": "#/components/schemas/NodeAttributes"
+ relationships:
+ "$ref": "#/components/schemas/NodeRelationships"
+ NodeAttributes:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Display name of the navigation item
+ link_url:
+ type: string
+ nullable: true
+ description: Navigation link URL
+ link_title:
+ type: string
+ nullable: true
+ description: Link title attribute
+ link_nofollow:
+ type: boolean
+ nullable: true
+ description: Whether the link has rel=nofollow
+ NodeRelationships:
+ type: object
+ properties:
+ parent:
+ type: object
+ description: Parent node (null for root menu nodes)
+ properties:
+ data:
+ nullable: true
+ allOf:
+ - "$ref": "#/components/schemas/ResourceIdentifier"
+ page:
+ type: object
+ description: Associated Alchemy page (if this node links to an internal
+ page)
+ properties:
+ data:
+ nullable: true
+ allOf:
+ - "$ref": "#/components/schemas/ResourceIdentifier"
+ children:
+ type: object
+ description: Child navigation nodes
+ properties:
+ data:
+ type: array
+ items:
+ "$ref": "#/components/schemas/ResourceIdentifier"
+ IngredientBaseAttributes:
+ type: object
+ description: Common attributes shared by all ingredient types
+ properties:
+ role:
+ type: string
+ description: The role name defined in the element definition
+ value:
+ description: The primary value (type varies by ingredient kind)
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ deprecated:
+ type: boolean
+ IngredientTextAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ body:
+ type: string
+ nullable: true
+ description: Alias for value (legacy compatibility)
+ link:
+ type: string
+ nullable: true
+ link_url:
+ type: string
+ nullable: true
+ description: Alias for link
+ link_class_name:
+ type: string
+ nullable: true
+ link_target:
+ type: string
+ nullable: true
+ link_title:
+ type: string
+ nullable: true
+ dom_id:
+ type: string
+ nullable: true
+ description: DOM ID attribute (Alchemy 6.1+)
+ IngredientRichtextAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: Raw rich text HTML
+ body:
+ type: string
+ nullable: true
+ description: Alias for value
+ sanitized_body:
+ type: string
+ nullable: true
+ description: Sanitized HTML output
+ stripped_body:
+ type: string
+ nullable: true
+ description: Plain text with HTML tags stripped
+ IngredientPictureAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: URL to the rendered picture
+ title:
+ type: string
+ nullable: true
+ alt_text:
+ type: string
+ nullable: true
+ caption:
+ type: string
+ nullable: true
+ link_url:
+ type: string
+ nullable: true
+ link_class_name:
+ type: string
+ nullable: true
+ link_title:
+ type: string
+ nullable: true
+ link_target:
+ type: string
+ nullable: true
+ image_dimensions:
+ type: object
+ nullable: true
+ description: Present only when a picture is attached
+ properties:
+ width:
+ type: number
+ height:
+ type: number
+ srcset:
+ type: array
+ nullable: true
+ description: Responsive image source set (present only when a picture
+ is attached)
+ items:
+ type: object
+ properties:
+ url:
+ type: string
+ desc:
+ type: string
+ description: Width descriptor (e.g. `800w`)
+ width:
+ type: string
+ height:
+ type: string
+ type:
+ type: string
+ description: MIME type
+ image_name:
+ type: string
+ nullable: true
+ description: Picture name (present only when a picture is attached)
+ image_file_name:
+ type: string
+ nullable: true
+ image_mime_type:
+ type: string
+ nullable: true
+ image_file_size:
+ type: integer
+ nullable: true
+ IngredientVideoAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: URL to the video attachment
+ width:
+ type: integer
+ nullable: true
+ height:
+ type: integer
+ nullable: true
+ allow_fullscreen:
+ type: boolean
+ nullable: true
+ autoplay:
+ type: boolean
+ nullable: true
+ controls:
+ type: boolean
+ nullable: true
+ preload:
+ type: string
+ nullable: true
+ video_name:
+ type: string
+ nullable: true
+ description: Present only when an attachment exists
+ video_file_name:
+ type: string
+ nullable: true
+ video_mime_type:
+ type: string
+ nullable: true
+ video_file_size:
+ type: integer
+ nullable: true
+ IngredientAudioAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: URL to the audio attachment
+ autoplay:
+ type: boolean
+ nullable: true
+ controls:
+ type: boolean
+ nullable: true
+ muted:
+ type: boolean
+ nullable: true
+ loop:
+ type: boolean
+ nullable: true
+ audio_name:
+ type: string
+ nullable: true
+ description: Present only when an attachment exists
+ audio_file_name:
+ type: string
+ nullable: true
+ audio_mime_type:
+ type: string
+ nullable: true
+ audio_file_size:
+ type: integer
+ nullable: true
+ IngredientFileAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: URL to the file attachment
+ link_title:
+ type: string
+ nullable: true
+ attachment_name:
+ type: string
+ nullable: true
+ description: Present only when an attachment exists
+ attachment_file_name:
+ type: string
+ nullable: true
+ attachment_mime_type:
+ type: string
+ nullable: true
+ attachment_file_size:
+ type: integer
+ nullable: true
+ IngredientPageAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: URL path of the referenced page
+ page_name:
+ type: string
+ nullable: true
+ page_url:
+ type: string
+ nullable: true
+ IngredientNodeAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: Name of the referenced node
+ name:
+ type: string
+ nullable: true
+ description: Present only when a node is attached
+ link_url:
+ type: string
+ nullable: true
+ link_title:
+ type: string
+ nullable: true
+ link_nofollow:
+ type: boolean
+ nullable: true
+ IngredientLinkAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: The link URL
+ link_class_name:
+ type: string
+ nullable: true
+ link_target:
+ type: string
+ nullable: true
+ link_title:
+ type: string
+ nullable: true
+ IngredientHeadlineAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: Headline text
+ level:
+ type: integer
+ nullable: true
+ description: Heading level (1-6)
+ size:
+ type: string
+ nullable: true
+ description: CSS size class
+ dom_id:
+ type: string
+ nullable: true
+ description: DOM ID attribute (Alchemy 6.1+)
+ IngredientBooleanAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: boolean
+ nullable: true
+ IngredientColorAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: Color value (e.g. hex code)
+ IngredientDatetimeAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ format: date-time
+ nullable: true
+ IngredientHtmlAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: Raw HTML content
+ IngredientSelectAttributes:
+ allOf:
+ - "$ref": "#/components/schemas/IngredientBaseAttributes"
+ - type: object
+ properties:
+ value:
+ type: string
+ nullable: true
+ description: Selected option value
+ ResourceIdentifier:
+ type: object
+ required:
+ - id
+ - type
+ properties:
+ id:
+ type: string
+ type:
+ type: string
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index af57489..6dba785 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -13,6 +13,7 @@
require "shoulda-matchers"
require "alchemy/json_api/test_support/ingredient_serializer_behaviour"
+require_relative "support/openapi"
Shoulda::Matchers.configure do |config|
config.integrate do |with|
diff --git a/spec/requests/alchemy/json_api/openapi_spec.rb b/spec/requests/alchemy/json_api/openapi_spec.rb
new file mode 100644
index 0000000..ca0f3f0
--- /dev/null
+++ b/spec/requests/alchemy/json_api/openapi_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Alchemy::JsonApi::Openapi", type: :request do
+ describe "GET /alchemy/json_api/openapi" do
+ it "returns the OpenAPI spec as JSON" do
+ get alchemy_json_api.openapi_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type).to include("application/json")
+
+ document = JSON.parse(response.body)
+ expect(document["openapi"]).to start_with("3.0")
+ expect(document["info"]["title"]).to eq("Alchemy CMS JSON API")
+ expect(document["paths"]).to include("/pages", "/pages/{path}", "/nodes")
+ end
+ end
+
+ describe "GET /alchemy/json_api/docs" do
+ context "in development environment" do
+ before do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
+ Rails.application.reload_routes!
+ end
+
+ it "returns an HTML page with Swagger UI in development", :aggregate_failures do
+ get alchemy_json_api.docs_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type).to include("text/html")
+ expect(response.body).to include("swagger-ui")
+ expect(response.body).to include(alchemy_json_api.openapi_path)
+ end
+
+ after do
+ allow(Rails).to receive(:env).and_call_original
+ Rails.application.reload_routes!
+ end
+ end
+
+ it "is not routable in non-development environments" do
+ get alchemy_json_api.docs_path
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb
new file mode 100644
index 0000000..e62ee9b
--- /dev/null
+++ b/spec/support/openapi.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+if ENV["OPENAPI"]
+ require "rspec/openapi"
+
+ RSpec::OpenAPI.path = File.expand_path("../../docs/openapi.yml", __dir__)
+ RSpec::OpenAPI.title = "Alchemy CMS JSON API"
+ RSpec::OpenAPI.application_version = Alchemy::JsonApi::VERSION
+ RSpec::OpenAPI.info = {
+ description: <<~DESC.strip,
+ A [JSON:API](https://jsonapi.org/) compliant API for [AlchemyCMS](https://alchemy-cms.com).
+
+ All responses follow the JSON:API specification. Use the `include` query parameter to
+ sideload related resources, `filter` for Ransack-based filtering, and `page` for pagination.
+
+ ## Authentication
+
+ Public endpoints serve published content without authentication. Admin endpoints require
+ an authenticated user with `edit_content` permission on `Alchemy::Page`.
+
+ ## Caching
+
+ Responses include HTTP caching headers (ETag, Cache-Control). Page endpoints default to
+ a max-age of 600 seconds (configurable via `ALCHEMY_JSON_API_CACHE_DURATION` env var).
+ Node endpoints default to 3 hours. Restricted pages are never publicly cached.
+ DESC
+ license: {
+ name: "BSD-3-Clause",
+ url: "https://opensource.org/licenses/BSD-3-Clause"
+ },
+ contact: {
+ name: "AlchemyCMS",
+ url: "https://www.alchemy-cms.com/"
+ }
+ }
+
+ RSpec::OpenAPI.comment = <<~COMMENT
+ This file is auto-generated by rspec-openapi.
+ Run `OPENAPI=1 bundle exec rspec` to regenerate.
+ COMMENT
+
+ RSpec::OpenAPI.servers = [{url: "/jsonapi", description: "Mounted engine path (default)"}]
+ RSpec::OpenAPI.response_headers = %w[ETag Cache-Control Last-Modified]
+ RSpec::OpenAPI.example_types = %i[request]
+
+ # Exclude the openapi and docs endpoints from generation
+ RSpec::OpenAPI.ignored_paths = [/openapi/, /docs/]
+
+ # Tag endpoints by controller name
+ RSpec::OpenAPI.tags_builder = ->(example) {
+ controller = example.metadata.dig(:request, :controller) ||
+ example.metadata[:described_class].to_s
+ case controller
+ when /Admin::LayoutPages/ then ["Admin Layout Pages"]
+ when /Admin::Pages/ then ["Admin Pages"]
+ when /LayoutPages/ then ["Layout Pages"]
+ when /Nodes/ then ["Nodes"]
+ when /Pages/ then ["Pages"]
+ else []
+ end
+ }
+end