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