From af9f6167e5227a247a234da4c41e384fe7b41d28 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:31:37 +0100 Subject: [PATCH 1/9] Add OpenAPI 3.0 documentation for the JSON:API endpoints Covers all public and admin endpoints (pages, layout_pages, nodes), all resource schemas (page, element, language, node), all 15 ingredient type schemas, pagination, filtering, sparse fieldsets, and caching headers. --- docs/openapi.yml | 1185 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1185 insertions(+) create mode 100644 docs/openapi.yml diff --git a/docs/openapi.yml b/docs/openapi.yml new file mode 100644 index 0000000..b65fc9d --- /dev/null +++ b/docs/openapi.yml @@ -0,0 +1,1185 @@ +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://github.com/AlchemyCMS/alchemy-json_api + +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: + /pages: + get: + operationId: listPages + summary: List content pages + description: | + Returns a paginated, filterable list of content pages for the current language. + Supports Ransack-based filtering via `filter` query parameters. + tags: + - Pages + parameters: + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + - $ref: "#/components/parameters/SortParam" + - $ref: "#/components/parameters/PageNumberParam" + - $ref: "#/components/parameters/PageSizeParam" + - name: filter[name_eq] + in: query + description: "Filter pages by exact name match (Ransack predicate). Other Ransack predicates on `Page` attributes are also supported (e.g. `filter[urlname_cont]`, `filter[page_layout_eq]`)." + required: false + schema: + type: string + responses: + "200": + description: A paginated list of pages + headers: + ETag: + $ref: "#/components/headers/ETag" + Cache-Control: + $ref: "#/components/headers/Cache-Control" + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/PageResource" + included: + type: array + items: + oneOf: + - $ref: "#/components/schemas/LanguageResource" + - $ref: "#/components/schemas/ElementResource" + - $ref: "#/components/schemas/NodeResource" + meta: + $ref: "#/components/schemas/PaginationMeta" + "404": + $ref: "#/components/responses/NotFound" + + /pages/{path}: + get: + operationId: showPage + summary: Get a content page + description: | + Returns a single content page by its numeric ID or URL name (slug path). + tags: + - Pages + parameters: + - name: path + in: path + required: true + description: Page ID (numeric) or urlname (slug path, e.g. `about/team`) + schema: + type: string + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + responses: + "200": + description: A single page + headers: + ETag: + $ref: "#/components/headers/ETag" + Cache-Control: + $ref: "#/components/headers/Cache-Control" + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/PageResource" + included: + type: array + items: + oneOf: + - $ref: "#/components/schemas/LanguageResource" + - $ref: "#/components/schemas/ElementResource" + - $ref: "#/components/schemas/NodeResource" + "404": + $ref: "#/components/responses/NotFound" + + /layout_pages: + get: + operationId: listLayoutPages + summary: List layout pages + description: | + Returns a paginated list of layout pages for the current language. + Layout pages are used for global partials such as headers and footers. + tags: + - Layout Pages + parameters: + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + - $ref: "#/components/parameters/SortParam" + - $ref: "#/components/parameters/PageNumberParam" + - $ref: "#/components/parameters/PageSizeParam" + responses: + "200": + description: A paginated list of layout pages + headers: + ETag: + $ref: "#/components/headers/ETag" + Cache-Control: + $ref: "#/components/headers/Cache-Control" + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/PageResource" + included: + type: array + items: + oneOf: + - $ref: "#/components/schemas/LanguageResource" + - $ref: "#/components/schemas/ElementResource" + - $ref: "#/components/schemas/NodeResource" + meta: + $ref: "#/components/schemas/PaginationMeta" + + /layout_pages/{path}: + get: + operationId: showLayoutPage + summary: Get a layout page + description: Returns a single layout page by its numeric ID or URL name. + tags: + - Layout Pages + parameters: + - name: path + in: path + required: true + description: Layout page ID (numeric) or urlname + schema: + type: string + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + responses: + "200": + description: A single layout page + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/PageResource" + included: + type: array + items: + oneOf: + - $ref: "#/components/schemas/LanguageResource" + - $ref: "#/components/schemas/ElementResource" + - $ref: "#/components/schemas/NodeResource" + "404": + $ref: "#/components/responses/NotFound" + + /nodes: + get: + operationId: listNodes + summary: List navigation nodes + description: | + Returns a paginated list of navigation menu nodes. Use the `include` parameter + to load nested associations (e.g. `include=children,page`). + tags: + - Nodes + parameters: + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + - $ref: "#/components/parameters/PageNumberParam" + - $ref: "#/components/parameters/PageSizeParam" + responses: + "200": + description: A paginated list of nodes + headers: + ETag: + $ref: "#/components/headers/ETag" + Cache-Control: + $ref: "#/components/headers/Cache-Control" + Last-Modified: + description: Timestamp of the most recently updated node + schema: + type: string + format: date-time + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/NodeResource" + included: + type: array + items: + oneOf: + - $ref: "#/components/schemas/NodeResource" + - $ref: "#/components/schemas/PageResource" + meta: + $ref: "#/components/schemas/PaginationMeta" + + /admin/pages/{path}: + get: + operationId: showAdminPage + summary: Get a draft content page (admin) + description: | + Returns a single content page with its **draft version** for editor preview. + Requires authentication with `edit_content` permission. Responses are never cached. + tags: + - Admin Pages + security: + - cookieAuth: [] + parameters: + - name: path + in: path + required: true + description: Page ID (numeric) or urlname + schema: + type: string + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + responses: + "200": + description: A single page (draft version) + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/PageResource" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /admin/layout_pages: + get: + operationId: listAdminLayoutPages + summary: List draft layout pages (admin) + description: | + Returns a paginated list of layout pages with draft versions for editor preview. + Requires authentication with `edit_content` permission. + tags: + - Admin Layout Pages + security: + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + - $ref: "#/components/parameters/PageNumberParam" + - $ref: "#/components/parameters/PageSizeParam" + responses: + "200": + description: A paginated list of layout pages (draft versions) + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/PageResource" + meta: + $ref: "#/components/schemas/PaginationMeta" + "403": + $ref: "#/components/responses/Forbidden" + + /admin/layout_pages/{path}: + get: + operationId: showAdminLayoutPage + summary: Get a draft layout page (admin) + description: | + Returns a single layout page with its draft version for editor preview. + Requires authentication with `edit_content` permission. + tags: + - Admin Layout Pages + security: + - cookieAuth: [] + parameters: + - name: path + in: path + required: true + description: Layout page ID (numeric) or urlname + schema: + type: string + - $ref: "#/components/parameters/IncludeParam" + - $ref: "#/components/parameters/FieldsParam" + responses: + "200": + description: A single layout page (draft version) + content: + application/vnd.api+json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/PageResource" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + +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 + + # --------------------- + # Resource objects + # --------------------- + + 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" + + # --------------------- + # Ingredient types + # --------------------- + + 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 + + # --------------------- + # JSON:API primitives + # --------------------- + + ResourceIdentifier: + type: object + required: + - id + - type + properties: + id: + type: string + type: + type: string From 56030048ea2356f4f300404f1aa27d5d55beb127 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:31:37 +0100 Subject: [PATCH 2/9] Add GET /openapi endpoint to serve the spec as JSON Adds OpenapiController that loads the YAML spec once and serves it as JSON. Clients and tools like Swagger UI can discover the API at /jsonapi/openapi.json. --- .../alchemy/json_api/openapi_controller.rb | 25 +++++++++++++++++++ config/routes.rb | 2 ++ .../requests/alchemy/json_api/openapi_spec.rb | 19 ++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 app/controllers/alchemy/json_api/openapi_controller.rb create mode 100644 spec/requests/alchemy/json_api/openapi_spec.rb 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..93b7558 --- /dev/null +++ b/app/controllers/alchemy/json_api/openapi_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "yaml" +require "json" + +module Alchemy + module JsonApi + class OpenapiController < ::ApplicationController + def show + render json: spec + 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/config/routes.rb b/config/routes.rb index 611cd58..a5ecd23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Alchemy::JsonApi::Engine.routes.draw do + get "openapi", to: "openapi#show", defaults: {format: :json} + resources :pages, only: [:index] get "pages/*path" => "pages#show", :as => :page resources :layout_pages, only: [:index] 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..9b7daf0 --- /dev/null +++ b/spec/requests/alchemy/json_api/openapi_spec.rb @@ -0,0 +1,19 @@ +# 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 +end From 2a8413221cf47e52aa536c6186347b314eecf675 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:31:37 +0100 Subject: [PATCH 3/9] Integrate rspec-openapi for automatic OpenAPI spec generation Add rspec-openapi to Gemfile and configure it in spec/support/openapi.rb to regenerate docs/openapi.yml from request specs when running with OPENAPI=1. The gem merges actual JSON:API responses into the existing spec file, preserving hand-written component schemas. Also includes docs/openapi.yml in the gemspec file list and documents the workflow in the README. --- Gemfile | 2 ++ README.md | 24 +++++++++++++++++++ alchemy-json_api.gemspec | 2 +- spec/rails_helper.rb | 1 + spec/support/openapi.rb | 51 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 spec/support/openapi.rb diff --git a/Gemfile b/Gemfile index c8bcb7b..f504341 100644 --- a/Gemfile +++ b/Gemfile @@ -26,3 +26,5 @@ gem "standard", "~> 1.25", require: false gem "pry-byebug" gem "propshaft", "~> 1.3" + +gem "rspec-openapi", require: false diff --git a/README.md b/README.md index da0f0f8..c15f3db 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,30 @@ 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. + ## Contributing Contribution directions go here. 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/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/support/openapi.rb b/spec/support/openapi.rb new file mode 100644 index 0000000..83f0bd5 --- /dev/null +++ b/spec/support/openapi.rb @@ -0,0 +1,51 @@ +# 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 compliant API for AlchemyCMS. + + 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. + 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 endpoint itself from generation + RSpec::OpenAPI.ignored_paths = [/openapi/] + + # 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 From d8f6530896d143124d7d9d1768c188e0edb3fa7a Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:31:37 +0100 Subject: [PATCH 4/9] Add OpenAPI spec drift check to CI Runs the test suite with OPENAPI=1 and fails if docs/openapi.yml has uncommitted changes, ensuring the checked-in spec stays in sync with the actual API responses. --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16dff2c..f24932d 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 --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: From ab71512a5cf41c3e9a029ef87b16c1c1fc6315e1 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:31:38 +0100 Subject: [PATCH 5/9] Update README with contributing guide and OpenAPI CI note Expand the Contributing section with step-by-step instructions including the OpenAPI regeneration step. Add a note to the OpenAPI section about the CI drift check. --- README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c15f3db..5db9302 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,32 @@ 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 From a92448896bbb2c6ab876ab95eb65686dc26ed89b Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:49:09 +0100 Subject: [PATCH 6/9] doc: Add Bruno collection Useful for working with Bruno App --- docs/bruno/.gitignore | 9 +++++++++ docs/bruno/opencollection.yml | 10 ++++++++++ 2 files changed, 19 insertions(+) create mode 100644 docs/bruno/.gitignore create mode 100644 docs/bruno/opencollection.yml 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 From f34743dd963a71e12a981e3a834539ec0eed28c4 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 22:49:30 +0100 Subject: [PATCH 7/9] chore: Add puma Necessary if you want to serve from the dummy app. --- Gemfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gemfile b/Gemfile index f504341..3b53079 100644 --- a/Gemfile +++ b/Gemfile @@ -28,3 +28,5 @@ gem "pry-byebug" gem "propshaft", "~> 1.3" gem "rspec-openapi", require: false + +gem "puma", "~> 7.2" From f5e4e87a64df54afe63c86c1181910323fe624a4 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sat, 21 Mar 2026 23:18:24 +0100 Subject: [PATCH 8/9] Add Swagger UI docs endpoint at /jsonapi/docs Serves a lightweight HTML page that loads Swagger UI from the CDN and points it at the existing /jsonapi/openapi.json endpoint. No extra gem dependencies required. Signed-off-by: Thomas von Deyen --- .../alchemy/json_api/openapi_controller.rb | 5 ++++ .../alchemy/json_api/openapi/docs.html.erb | 22 +++++++++++++++ config/routes.rb | 3 +- .../requests/alchemy/json_api/openapi_spec.rb | 28 +++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 app/views/alchemy/json_api/openapi/docs.html.erb diff --git a/app/controllers/alchemy/json_api/openapi_controller.rb b/app/controllers/alchemy/json_api/openapi_controller.rb index 93b7558..0f1c524 100644 --- a/app/controllers/alchemy/json_api/openapi_controller.rb +++ b/app/controllers/alchemy/json_api/openapi_controller.rb @@ -10,6 +10,11 @@ def show render json: spec end + def docs + @spec_url = alchemy_json_api.openapi_path(format: :json) + render layout: false + end + private def spec 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 a5ecd23..0a75ffa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true Alchemy::JsonApi::Engine.routes.draw do - get "openapi", to: "openapi#show", defaults: {format: :json} + 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 diff --git a/spec/requests/alchemy/json_api/openapi_spec.rb b/spec/requests/alchemy/json_api/openapi_spec.rb index 9b7daf0..ca0f3f0 100644 --- a/spec/requests/alchemy/json_api/openapi_spec.rb +++ b/spec/requests/alchemy/json_api/openapi_spec.rb @@ -16,4 +16,32 @@ 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 From 5410f0ba2b97ae4cd0dd93ed352607c22729d6f2 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Sun, 22 Mar 2026 00:10:03 +0100 Subject: [PATCH 9/9] Update generated OpenAPI spec Update generated OpenAPI spec with tags, schemas, and component definitions. Signed-off-by: Thomas von Deyen --- .github/workflows/ci.yml | 2 +- docs/openapi.yml | 1389 +++++++++++++++++++------------------- spec/support/openapi.rb | 17 +- 3 files changed, 728 insertions(+), 680 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f24932d..f46f06c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: run: bundle exec rake - name: Check for OpenAPI spec drift run: | - if ! git diff --exit-code docs/openapi.yml; then + 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 diff --git a/docs/openapi.yml b/docs/openapi.yml index b65fc9d..b17fd74 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -1,7 +1,10 @@ +# 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: | + 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 @@ -23,343 +26,424 @@ info: url: https://opensource.org/licenses/BSD-3-Clause contact: name: AlchemyCMS - url: https://github.com/AlchemyCMS/alchemy-json_api - + url: https://www.alchemy-cms.com/ servers: - - url: /jsonapi - description: Mounted engine path (default) - +- 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) - +- 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: - /pages: + "/jsonapi/admin/layout_pages": get: - operationId: listPages - summary: List content pages - description: | - Returns a paginated, filterable list of content pages for the current language. - Supports Ransack-based filtering via `filter` query parameters. - tags: - - Pages - parameters: - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" - - $ref: "#/components/parameters/SortParam" - - $ref: "#/components/parameters/PageNumberParam" - - $ref: "#/components/parameters/PageSizeParam" - - name: filter[name_eq] - in: query - description: "Filter pages by exact name match (Ransack predicate). Other Ransack predicates on `Page` attributes are also supported (e.g. `filter[urlname_cont]`, `filter[page_layout_eq]`)." - required: false - schema: - type: string + summary: index + tags: [] responses: - "200": - description: A paginated list of pages + '200': + description: returns paginated result headers: ETag: - $ref: "#/components/headers/ETag" + schema: + type: string Cache-Control: - $ref: "#/components/headers/Cache-Control" + schema: + type: string content: application/vnd.api+json: schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/PageResource" - included: - type: array - items: - oneOf: - - $ref: "#/components/schemas/LanguageResource" - - $ref: "#/components/schemas/ElementResource" - - $ref: "#/components/schemas/NodeResource" - meta: - $ref: "#/components/schemas/PaginationMeta" - "404": - $ref: "#/components/responses/NotFound" - - /pages/{path}: + 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: - operationId: showPage - summary: Get a content page - description: | - Returns a single content page by its numeric ID or URL name (slug path). - tags: - - Pages + summary: show + tags: [] parameters: - - name: path - in: path - required: true - description: Page ID (numeric) or urlname (slug path, e.g. `about/team`) - schema: - type: string - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" + - name: path + in: path + required: true + schema: + oneOf: + - type: integer + - type: string + example: a-page-9 responses: - "200": - description: A single page + '200': + description: gets a valid JSON:API document headers: ETag: - $ref: "#/components/headers/ETag" + schema: + type: string Cache-Control: - $ref: "#/components/headers/Cache-Control" + schema: + type: string content: application/vnd.api+json: schema: - type: object - properties: - data: - $ref: "#/components/schemas/PageResource" - included: - type: array - items: - oneOf: - - $ref: "#/components/schemas/LanguageResource" - - $ref: "#/components/schemas/ElementResource" - - $ref: "#/components/schemas/NodeResource" - "404": - $ref: "#/components/responses/NotFound" - - /layout_pages: + 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: - operationId: listLayoutPages - summary: List layout pages - description: | - Returns a paginated list of layout pages for the current language. - Layout pages are used for global partials such as headers and footers. - tags: - - Layout Pages + summary: show + tags: [] parameters: - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" - - $ref: "#/components/parameters/SortParam" - - $ref: "#/components/parameters/PageNumberParam" - - $ref: "#/components/parameters/PageSizeParam" + - name: path + in: path + required: true + schema: + oneOf: + - type: integer + - type: string + example: a-page-15 responses: - "200": - description: A paginated list of layout pages + '200': + description: gets a valid JSON:API document headers: ETag: - $ref: "#/components/headers/ETag" + schema: + type: string Cache-Control: - $ref: "#/components/headers/Cache-Control" + schema: + type: string content: application/vnd.api+json: schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/PageResource" - included: - type: array - items: - oneOf: - - $ref: "#/components/schemas/LanguageResource" - - $ref: "#/components/schemas/ElementResource" - - $ref: "#/components/schemas/NodeResource" - meta: - $ref: "#/components/schemas/PaginationMeta" - - /layout_pages/{path}: + 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: - operationId: showLayoutPage - summary: Get a layout page - description: Returns a single layout page by its numeric ID or URL name. - tags: - - Layout Pages - parameters: - - name: path - in: path - required: true - description: Layout page ID (numeric) or urlname - schema: - type: string - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" + summary: index + tags: [] responses: - "200": - description: A single layout page + '200': + description: returns paginated result + headers: + ETag: + schema: + type: string + Cache-Control: + schema: + type: string content: application/vnd.api+json: schema: - type: object - properties: - data: - $ref: "#/components/schemas/PageResource" - included: - type: array - items: - oneOf: - - $ref: "#/components/schemas/LanguageResource" - - $ref: "#/components/schemas/ElementResource" - - $ref: "#/components/schemas/NodeResource" - "404": - $ref: "#/components/responses/NotFound" - - /nodes: + 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: - operationId: listNodes - summary: List navigation nodes - description: | - Returns a paginated list of navigation menu nodes. Use the `include` parameter - to load nested associations (e.g. `include=children,page`). - tags: - - Nodes + summary: show + tags: [] parameters: - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" - - $ref: "#/components/parameters/PageNumberParam" - - $ref: "#/components/parameters/PageSizeParam" + - name: path + in: path + required: true + schema: + oneOf: + - type: integer + - type: string + example: a-page-17 responses: - "200": - description: A paginated list of nodes + '200': + description: finds the page headers: ETag: - $ref: "#/components/headers/ETag" + schema: + type: string Cache-Control: - $ref: "#/components/headers/Cache-Control" - Last-Modified: - description: Timestamp of the most recently updated node schema: type: string - format: date-time content: application/vnd.api+json: schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/NodeResource" - included: - type: array - items: - oneOf: - - $ref: "#/components/schemas/NodeResource" - - $ref: "#/components/schemas/PageResource" - meta: - $ref: "#/components/schemas/PaginationMeta" - - /admin/pages/{path}: + 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: - operationId: showAdminPage - summary: Get a draft content page (admin) - description: | - Returns a single content page with its **draft version** for editor preview. - Requires authentication with `edit_content` permission. Responses are never cached. - tags: - - Admin Pages - security: - - cookieAuth: [] - parameters: - - name: path - in: path - required: true - description: Page ID (numeric) or urlname - schema: - type: string - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" + summary: index + tags: [] responses: - "200": - description: A single page (draft version) + '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: object - properties: - data: - $ref: "#/components/schemas/PageResource" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - - /admin/layout_pages: + 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: - operationId: listAdminLayoutPages - summary: List draft layout pages (admin) - description: | - Returns a paginated list of layout pages with draft versions for editor preview. - Requires authentication with `edit_content` permission. - tags: - - Admin Layout Pages - security: - - cookieAuth: [] + summary: index + tags: [] parameters: - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" - - $ref: "#/components/parameters/PageNumberParam" - - $ref: "#/components/parameters/PageSizeParam" + - 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: A paginated list of layout pages (draft versions) + '200': + description: returns paginated result + headers: + ETag: + schema: + type: string + Cache-Control: + schema: + type: string content: application/vnd.api+json: schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/PageResource" - meta: - $ref: "#/components/schemas/PaginationMeta" - "403": - $ref: "#/components/responses/Forbidden" - - /admin/layout_pages/{path}: + 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: - operationId: showAdminLayoutPage - summary: Get a draft layout page (admin) - description: | - Returns a single layout page with its draft version for editor preview. - Requires authentication with `edit_content` permission. - tags: - - Admin Layout Pages - security: - - cookieAuth: [] + summary: show + tags: [] parameters: - - name: path - in: path - required: true - description: Layout page ID (numeric) or urlname - schema: - type: string - - $ref: "#/components/parameters/IncludeParam" - - $ref: "#/components/parameters/FieldsParam" + - 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: A single layout page (draft version) + '200': + description: finds the page + headers: + ETag: + schema: + type: string + Cache-Control: + schema: + type: string content: application/vnd.api+json: schema: - type: object - properties: - data: - $ref: "#/components/schemas/PageResource" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - + 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: @@ -367,7 +451,6 @@ components: in: cookie name: _alchemy_session description: Alchemy CMS session cookie (required for admin endpoints) - parameters: IncludeParam: name: include @@ -381,7 +464,6 @@ components: schema: type: string example: all_elements,all_elements.ingredients,language - FieldsParam: name: fields in: query @@ -395,7 +477,6 @@ components: type: object additionalProperties: type: string - SortParam: name: sort in: query @@ -405,7 +486,6 @@ components: Example: `-updated_at,name` schema: type: string - PageNumberParam: name: page[number] in: query @@ -415,7 +495,6 @@ components: type: integer minimum: 1 default: 1 - PageSizeParam: name: page[size] in: query @@ -424,7 +503,6 @@ components: schema: type: integer minimum: 1 - headers: ETag: description: Entity tag for conditional requests @@ -434,22 +512,20 @@ components: description: Caching directives schema: type: string - example: "max-age=600, public, must-revalidate" - + example: max-age=600, public, must-revalidate responses: NotFound: description: Resource not found content: application/vnd.api+json: schema: - $ref: "#/components/schemas/JsonApiErrors" + "$ref": "#/components/schemas/JsonApiErrors" Forbidden: description: Insufficient permissions content: application/vnd.api+json: schema: - $ref: "#/components/schemas/JsonApiErrors" - + "$ref": "#/components/schemas/JsonApiErrors" schemas: JsonApiErrors: type: object @@ -465,7 +541,6 @@ components: type: string detail: type: string - PaginationMeta: type: object properties: @@ -489,28 +564,22 @@ components: type: integer total: type: integer - - # --------------------- - # Resource objects - # --------------------- - PageResource: type: object required: - - id - - type + - id + - type properties: id: type: string type: type: string enum: - - page + - page attributes: - $ref: "#/components/schemas/PageAttributes" + "$ref": "#/components/schemas/PageAttributes" relationships: - $ref: "#/components/schemas/PageRelationships" - + "$ref": "#/components/schemas/PageRelationships" PageAttributes: type: object properties: @@ -558,7 +627,6 @@ components: deprecated: type: boolean description: Whether the page layout is marked as deprecated - PageRelationships: type: object properties: @@ -566,22 +634,23 @@ components: type: object properties: data: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" ancestors: type: object properties: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" all_elements: type: object - description: All public elements (top-level, fixed, and nested). Use for eager loading via `include`. + description: All public elements (top-level, fixed, and nested). Use for + eager loading via `include`. properties: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" elements: type: object description: Top-level, non-fixed public elements only @@ -589,7 +658,7 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" fixed_elements: type: object description: Top-level, fixed public elements only @@ -597,25 +666,23 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" - + "$ref": "#/components/schemas/ResourceIdentifier" ElementResource: type: object required: - - id - - type + - id + - type properties: id: type: string type: type: string enum: - - element + - element attributes: - $ref: "#/components/schemas/ElementAttributes" + "$ref": "#/components/schemas/ElementAttributes" relationships: - $ref: "#/components/schemas/ElementRelationships" - + "$ref": "#/components/schemas/ElementRelationships" ElementAttributes: type: object properties: @@ -637,7 +704,6 @@ components: deprecated: type: boolean description: Whether the element definition is marked as deprecated - ElementRelationships: type: object properties: @@ -650,7 +716,7 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" nested_elements: type: object description: Child elements nested within this element @@ -658,25 +724,23 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" - + "$ref": "#/components/schemas/ResourceIdentifier" LanguageResource: type: object required: - - id - - type + - id + - type properties: id: type: string type: type: string enum: - - language + - language attributes: - $ref: "#/components/schemas/LanguageAttributes" + "$ref": "#/components/schemas/LanguageAttributes" relationships: - $ref: "#/components/schemas/LanguageRelationships" - + "$ref": "#/components/schemas/LanguageRelationships" LanguageAttributes: type: object properties: @@ -692,7 +756,6 @@ components: locale: type: string description: Full locale string (e.g. `en_US`) - LanguageRelationships: type: object properties: @@ -703,7 +766,7 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" menus: type: object description: Root-level navigation nodes (menus) for this language @@ -711,37 +774,35 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" pages: type: object properties: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" + "$ref": "#/components/schemas/ResourceIdentifier" root_page: type: object properties: data: - $ref: "#/components/schemas/ResourceIdentifier" - + "$ref": "#/components/schemas/ResourceIdentifier" NodeResource: type: object required: - - id - - type + - id + - type properties: id: type: string type: type: string enum: - - node + - node attributes: - $ref: "#/components/schemas/NodeAttributes" + "$ref": "#/components/schemas/NodeAttributes" relationships: - $ref: "#/components/schemas/NodeRelationships" - + "$ref": "#/components/schemas/NodeRelationships" NodeAttributes: type: object properties: @@ -760,7 +821,6 @@ components: type: boolean nullable: true description: Whether the link has rel=nofollow - NodeRelationships: type: object properties: @@ -771,15 +831,16 @@ components: data: nullable: true allOf: - - $ref: "#/components/schemas/ResourceIdentifier" + - "$ref": "#/components/schemas/ResourceIdentifier" page: type: object - description: Associated Alchemy page (if this node links to an internal page) + description: Associated Alchemy page (if this node links to an internal + page) properties: data: nullable: true allOf: - - $ref: "#/components/schemas/ResourceIdentifier" + - "$ref": "#/components/schemas/ResourceIdentifier" children: type: object description: Child navigation nodes @@ -787,12 +848,7 @@ components: data: type: array items: - $ref: "#/components/schemas/ResourceIdentifier" - - # --------------------- - # Ingredient types - # --------------------- - + "$ref": "#/components/schemas/ResourceIdentifier" IngredientBaseAttributes: type: object description: Common attributes shared by all ingredient types @@ -810,374 +866,355 @@ components: 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+) - + - "$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 - + - "$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: + - "$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 - nullable: true - description: Present only when a picture is attached properties: + url: + type: string + desc: + type: string + description: Width descriptor (e.g. `800w`) width: - type: number + type: string 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 - + 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 - + - "$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 - + - "$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 - + - "$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 - + - "$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 - + - "$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 - + - "$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+) - + - "$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 - + - "$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) - + - "$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 - + - "$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 - + - "$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 - - # --------------------- - # JSON:API primitives - # --------------------- - + - "$ref": "#/components/schemas/IngredientBaseAttributes" + - type: object + properties: + value: + type: string + nullable: true + description: Selected option value ResourceIdentifier: type: object required: - - id - - type + - id + - type properties: id: type: string diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 83f0bd5..e62ee9b 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -8,10 +8,21 @@ RSpec::OpenAPI.application_version = Alchemy::JsonApi::VERSION RSpec::OpenAPI.info = { description: <<~DESC.strip, - A JSON:API compliant API for AlchemyCMS. + 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", @@ -32,8 +43,8 @@ RSpec::OpenAPI.response_headers = %w[ETag Cache-Control Last-Modified] RSpec::OpenAPI.example_types = %i[request] - # Exclude the openapi endpoint itself from generation - RSpec::OpenAPI.ignored_paths = [/openapi/] + # Exclude the openapi and docs endpoints from generation + RSpec::OpenAPI.ignored_paths = [/openapi/, /docs/] # Tag endpoints by controller name RSpec::OpenAPI.tags_builder = ->(example) {