openapi: 3.0.3 info: title: Pireon Admin API version: "1.0.0" description: | REST API of the admin application. ## Two APIs in one document - **`/public/v1/*`** — unauthenticated, read-only. Consumed by the public frontend, which renders server-side. Everything it returns is already published on the public website. - **`/v1/*`** — authenticated administration, described further down. ## Authentication Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued by the **Pireon identity server** (`https://oauth.pireon.dev`). This application does not issue tokens of its own; it validates the ones the identity server issued. Obtain a token through `POST /v1/auth/token`, which proxies the identity server's password grant. The panel's own sign-in form uses the same grant, so `PIREON_PASSWORD_GRANT=false` closes both. Authorization Code + PKCE against `https://oauth.pireon.dev/oauth/authorize` remains available for clients that prefer never to handle a password. ## Authorisation The identity server establishes *who* the caller is. What they may *do* is decided here, by the roles and permissions administered in this app's panel. `GET /v1/me` returns the caller's effective permissions; drive your UI from that array rather than from role names. ## Languages Content fields (`title`, `slug`, `excerpt`, `body`, …) are translatable. By default they are returned resolved to a single language, chosen as: 1. `?lang=xx` 2. the user's stored preference 3. the `Accept-Language` header 4. the application default (`es`) Pass `?with=translations` to receive every language alongside, and send a `{"es": "...", "ca": "..."}` object to write specific languages. A write that names only some languages leaves the others untouched. servers: - url: /api description: API base URL tags: - name: Public description: | Unauthenticated, read-only endpoints for the public frontend. Records are addressed **by slug within the active locale**, because that is the access pattern a server-rendered frontend has: it receives a URL, not an id. A slug only resolves in the language it belongs to, so one record never answers on two addresses. Every single-record response carries `alternates` — the same record's path in each language that actually has a translation. Only this API knows that `/proyectos/mi-web` and `/ca/projectes/el-meu-web` are one thing, so without it the frontend cannot emit correct `hreflang` tags. Locales with no translation are omitted rather than pointing at a URL that would 404. Responses carry `ETag`, `Cache-Control: s-maxage`, `stale-while-revalidate` and `Vary: Accept-Language`. Send `If-None-Match` to revalidate cheaply, and watch `content_version` from `/site` to know when a cached copy is genuinely out of date. Unpublished records can be reached two ways: the shared preview secret (`X-Preview-Token`), which unlocks everything, or a signed link generated in the panel for one record (`?preview=…`), which expires and unlocks only that one. - name: Submissions description: | The public API's only write endpoints: the two forms the site offers. They carry their own throttle, a honeypot and an off switch, because they are the one place a stranger can put a row in the database. What arrives lands in the panel's inbox, and the people who can read it are notified. - name: Telemetry description: | The frontend reporting back, rather than asking for something. Also a write endpoint, but the author is the frontend itself and not a visitor, so unlike the submissions it holds a shared secret (`X-Ingest-Token`) instead of a honeypot. An unset secret disables the endpoint; it never opens it. Two things arrive here: addresses that failed, which are the raw material of a redirect, and records that were read, which is the only way this side can know whether anything it publishes is opened. Neither carries anything about a person — no address, no session, no user agent, no time of day. - name: Auth description: Token exchange and caller identity - name: Pages description: Translatable page content - name: Categories description: Translatable page categories - name: Users description: Local user administration - name: Roles description: Local authorisation model (read-only) security: - bearerAuth: [] paths: /public/v1/site: get: tags: [Public] summary: Locales and headline numbers description: What the frontend needs once on any page. security: [] responses: "200": description: Site metadata content: application/json: schema: type: object properties: data: type: object properties: locales: type: array items: type: object properties: code: { type: string, example: ca } name: { type: string, example: Català } flag: { type: string } is_default: { type: boolean } default_locale: { type: string, example: es } content_version: type: integer description: | Moves whenever published content does. Keep it as part of your cache key and everything downstream goes stale the moment the site really changed, rather than when a timer says so. submissions_enabled: type: boolean description: | Whether the forms are accepting messages. Hide them when false rather than rendering one that would be refused on submit. stats: type: object properties: projects: { type: integer } active_projects: { type: integer } members: { type: integer } open_calls: { type: integer } good_first_issues: { type: integer } stars: { type: integer } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/slugs: get: tags: [Public] summary: Every public URL, grouped by type and locale description: | One request instead of paging through every collection — this is what the frontend builds its sitemap and route table from. security: [] responses: "200": description: All published URLs content: application/json: schema: type: object properties: data: type: object additionalProperties: type: array items: type: object properties: id: { type: integer } updated_at: { type: string, format: date-time } alternates: type: object additionalProperties: { type: string } example: es: /proyectos/mi-web ca: /ca/projectes/el-meu-web "503": { $ref: "#/components/responses/Maintenance" } /public/v1/redirects: get: tags: [Public] summary: URLs that used to work description: | Every enabled redirect, so the frontend can answer a 301 instead of a 404. Most were written by the panel itself the moment somebody renamed a slug — the only moment the old address was still known. Returned whole rather than looked up one at a time: there are never many, and an SSR frontend would otherwise call this API on every 404 it serves. security: [] responses: "200": description: Enabled redirects content: application/json: schema: type: object properties: data: type: array items: type: object properties: from: { type: string, example: /proyectos/nombre-viejo } to: { type: string, example: /proyectos/nombre-nuevo } status: { type: integer, enum: [301, 302] } locale: type: string nullable: true description: Null applies in every language. "503": { $ref: "#/components/responses/Maintenance" } /public/v1/projects: get: tags: [Public] summary: List published projects security: [] parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PerPage" - name: status in: query schema: { type: string, enum: [idea, active, paused, completed, archived] } - name: featured in: query schema: { type: boolean } - name: needs_help in: query description: Only projects looking for contributors. schema: { type: boolean } - name: technology in: query description: Technology slug; repeat for several. schema: { type: string } - name: member in: query schema: { type: string } - name: translated in: query description: Exclude projects with no translation in the active locale. schema: { type: boolean } responses: "200": description: Paginated projects content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicProject" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/projects/{slug}: get: tags: [Public] summary: A single project, with its write-up description: | Adds the long-form fields the listing omits: `description`, `decisions` and `postmortem`, plus members, open calls and live repository stats. security: [] parameters: - name: slug in: path required: true description: The slug in the active locale. schema: { type: string } - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PreviewToken" responses: "200": description: The project content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicProject" } "404": { description: No project with that slug in that language } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/members: get: tags: [Public] summary: List public members security: [] parameters: - $ref: "#/components/parameters/Lang" - name: include_past in: query description: Include people who have left, for an alumni section. schema: { type: boolean } - name: technology in: query schema: { type: string } responses: "200": description: Paginated members content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicMember" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/members/{slug}: get: tags: [Public] summary: A single member, with their projects security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" responses: "200": description: The member content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicMember" } "404": { description: Not found } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/calls: get: tags: [Public] summary: Open calls for contributors description: | Excludes closed calls, calls outside their window, and calls attached to an unpublished project. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: technology in: query schema: { type: string } - name: max_hours_per_week in: query description: '"I have four hours a week" — the filter people actually apply.' schema: { type: integer } responses: "200": description: Paginated calls content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicCall" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/calls/{slug}: get: tags: [Public] summary: A single open call security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" responses: "200": description: The call content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicCall" } "404": { description: Not found or no longer open } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/calls/{slug}/apply: post: tags: [Submissions] summary: Answer a call description: | The public API's only write endpoints are this one and `/public/v1/contact`. Only an **open** call accepts applications; anything else answers 404, because letting someone write a message for a position that is already filled wastes their time. Submitting the same address to the same call twice inside the deduplication window (24 h by default) answers **200** with `duplicate: true` and stores nothing further: a double-clicked submit button is not a second person. Throttling is per e-mail address plus a site-wide hourly ceiling — deliberately not per IP, since with an SSR frontend every submission arrives from the same address. security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/Submission" } responses: "201": { $ref: "#/components/responses/SubmissionAccepted" } "200": description: Recognised as a repeat of a submission already stored content: application/json: schema: { $ref: "#/components/schemas/SubmissionReceipt" } "404": { description: No open call with that slug in this language } "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many submissions from this address } "503": description: | The public site is in maintenance, or submissions are switched off (`SITE_ACCEPT_SUBMISSIONS=false`). The frontend can tell the two apart by the `maintenance` and `submissions_enabled` keys. /public/v1/contact: post: tags: [Submissions] summary: Write in without a specific call description: | The general form: somebody offering time with no call in mind, or an association asking for help. `kind` chooses between the two, and `organisation` becomes required when it is `request`. security: [] parameters: - $ref: "#/components/parameters/Lang" requestBody: required: true content: application/json: schema: allOf: - $ref: "#/components/schemas/Submission" - type: object properties: kind: type: string enum: [contribute, request] default: contribute responses: "201": { $ref: "#/components/responses/SubmissionAccepted" } "200": description: Recognised as a repeat of a submission already stored content: application/json: schema: { $ref: "#/components/schemas/SubmissionReceipt" } "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many submissions from this address } "503": { description: "Maintenance, or submissions switched off" } /public/v1/telemetry/not-found: post: tags: [Telemetry] summary: Report addresses the frontend could not resolve description: | Redirects were only ever written from this side: renaming a slug leaves a rule behind, because that is the one moment the old address is still known. Every other broken link — the previous website's, one printed on paper, another site linking a page that moved years ago — fails on the frontend, in front of a visitor, and never reaches the panel. This is where the frontend says so. Reports land in **Administration → Missing URLs**, where one becomes a redirect with a single action. Accepts either a single report or a batch of up to `SITE_TELEMETRY_MAX_BATCH` (50 by default). Paths are normalised exactly as redirects are — trailing slashes and query strings are stripped — and stored as one row per `(path, locale)` with a hit count, so a crawler costs one row rather than one per request. Obvious scans (`/wp-admin`, `/.env`, anything ending in `.php`) are dropped rather than stored. The response says how many of the reports were kept, so the frontend can tell its filter from this one. Not affected by maintenance mode: a 404 during a deploy is still a 404, and those are the most interesting ones. security: - ingestToken: [] parameters: - $ref: "#/components/parameters/IngestToken" requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/MissingUrlReport" - type: object required: [hits] properties: hits: type: array minItems: 1 maxItems: 50 items: { $ref: "#/components/schemas/MissingUrlReport" } responses: "202": description: | Reports accepted. Nothing here is a resource the caller owns, and a report merged into an existing count or dropped as noise is still successful — hence 202 rather than 201. content: application/json: schema: type: object properties: data: type: object properties: received: { type: integer, example: 3 } recorded: { type: integer, example: 1 } "403": description: | Missing or wrong `X-Ingest-Token`, or reporting is disabled because `SITE_INGEST_TOKEN` is unset. "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many reports from this address } /public/v1/telemetry/views: post: tags: [Telemetry] summary: Report what was read description: | The panel can say what exists and what is published. It has never been able to say whether any of it gets opened — which is the fact behind most editorial decisions: what to translate next, whether the featured project is one anybody looks at, whether a page earns the work of keeping it accurate. Reports are addressed the way the frontend already thinks: it fetched `/public/v1/projects/mi-web` to draw the page, so it reports a **type** and a **slug** rather than parsing its own URL back apart. A slug that no longer resolves is not counted — that is a 404, and `/telemetry/not-found` is where those belong. **There is no visitor in this.** No address, no session, no user agent, no referrer, no time of day. What is stored is one row per record, per language, per day, holding a number — enough for the questions above and not enough to reconstruct anybody. Reading figures are kept for 400 days and then pruned. Accepts a single report or a batch of up to `SITE_TELEMETRY_MAX_BATCH`. security: - ingestToken: [] parameters: - $ref: "#/components/parameters/IngestToken" requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/ViewReport" - type: object required: [views] properties: views: type: array minItems: 1 maxItems: 50 items: { $ref: "#/components/schemas/ViewReport" } responses: "202": description: | Reports accepted. `recorded` counts the ones that named something this API still serves. content: application/json: schema: type: object properties: data: type: object properties: received: { type: integer, example: 2 } recorded: { type: integer, example: 2 } "403": description: | Missing or wrong `X-Ingest-Token`, or reporting is disabled because `SITE_INGEST_TOKEN` is unset. "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many reports from this address } /public/v1/services: get: tags: [Public] summary: What the collective offers description: | The counterpart to a project: one says what has been built, this says what somebody would be asking for. Ordered as arranged in the panel. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: featured in: query schema: { type: boolean } responses: "200": description: Published services content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicService" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/services/{slug}: get: tags: [Public] summary: A single service description: | Carries the work that backs the offer and what people said about it — a service page with neither is a promise. security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" responses: "200": description: The service content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicService" } "404": { description: "Not found in this language, or not published" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/clients: get: tags: [Public] summary: The wall of logos description: | Organisations the work was for. Anyone who asked not to appear is absent whatever else is true of them, and by default so is anyone with no published project behind them — a logo the site could not justify showing. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: with_projects in: query description: Set to 0 to include clients with nothing published yet. schema: { type: boolean, default: true } responses: "200": description: Public clients content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicClient" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/testimonials: get: tags: [Public] summary: What other people said description: | Attached to a client, a project or a service rather than to a page, so ask for the ones belonging to whatever you are rendering. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: client in: query description: Client slug. schema: { type: string } - name: service in: query description: Service slug, in the active language. schema: { type: string } responses: "200": description: Public testimonials content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTestimonial" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/talks: get: tags: [Public] summary: Talks given elsewhere security: [] parameters: - $ref: "#/components/parameters/Lang" - name: with_video in: query schema: { type: boolean } - name: limit in: query schema: { type: integer, default: 50, maximum: 100 } responses: "200": description: Public talks, newest first content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTalk" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/pages: get: tags: [Public] summary: List published pages security: [] parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PerPage" - name: category in: query schema: { type: string } - name: featured in: query schema: { type: boolean } responses: "200": description: Paginated pages content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicPage" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/pages/{slug}: get: tags: [Public] summary: A single published page security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PreviewToken" responses: "200": description: The page content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicPage" } "404": { description: Not found } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/technologies: get: tags: [Public] summary: Technologies in use security: [] parameters: - $ref: "#/components/parameters/Lang" - name: category in: query schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } - name: in_use in: query description: Defaults to true — only technologies attached to a project. schema: { type: boolean } responses: "200": description: Technologies content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTechnology" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/radar: get: tags: [Public] summary: The tech radar description: | Technologies placed on a ring — adopt, trial, assess or hold — with the note explaining why. Technologies with no ring are omitted. security: [] parameters: - $ref: "#/components/parameters/Lang" responses: "200": description: Radar entries content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTechnology" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/good-first-issues: get: tags: [Public] summary: Newcomer-friendly open issues across all repositories description: | Mirrored from the git provider and refreshed hourly. Claimed issues, archived repositories and repositories hidden from the site are excluded — an issue somebody already took is not an invitation. security: [] parameters: - name: limit in: query schema: { type: integer, default: 20, maximum: 100 } - name: language in: query description: Filter by the repository's primary language. schema: { type: string, example: PHP } - name: include_help_wanted in: query schema: { type: boolean, default: true } responses: "200": description: Open issues content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicIssue" } "503": { $ref: "#/components/responses/Maintenance" } /v1/auth/token: post: tags: [Auth] summary: Exchange credentials or a refresh token for an access token description: | Proxies the identity server's `password` and `refresh_token` grants so first-party clients need not embed the OAuth server's URL or secret. Rate limited to 6 requests per minute per account and address, and 30 per address overall. Browser clients should use Authorization Code + PKCE instead. security: [] requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/PasswordGrantRequest" - $ref: "#/components/schemas/RefreshGrantRequest" responses: "200": description: Token issued content: application/json: schema: { $ref: "#/components/schemas/TokenResponse" } "401": { description: Credentials rejected by the identity server } "403": { description: Account inactive or awaiting approval } "422": { $ref: "#/components/responses/ValidationError" } /v1/me: get: tags: [Auth] summary: The current caller, with their effective local permissions responses: "200": description: The authenticated user content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } /v1/pages: get: tags: [Pages] summary: List pages description: | Callers without the `pages.publish` permission only ever see published pages. parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: status in: query schema: { type: string, enum: [draft, review, published, archived] } - name: category_id in: query schema: { type: integer } - name: is_featured in: query schema: { type: boolean } - name: search in: query description: Matches title and excerpt within the active language only. schema: { type: string } - name: translated_in in: query description: Only pages that actually carry a translation for this locale. schema: { type: string, enum: [es, en, ca] } - name: trashed in: query schema: { type: boolean } - name: sort in: query description: 'Prefix with "-" for descending, e.g. `-published_at`.' schema: type: string enum: [title, created_at, updated_at, published_at, status, -title, -created_at, -updated_at, -published_at, -status] responses: "200": description: Paginated pages content: application/json: schema: { $ref: "#/components/schemas/PageCollection" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Pages] summary: Create a page description: > Requires `pages.create`. Setting `status` to `published` additionally requires `pages.publish`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PageWrite" } responses: "201": description: Page created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}: parameters: - name: page in: path required: true schema: { type: integer } get: tags: [Pages] summary: Show a page parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The page content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Pages] summary: Update a page description: | Partial update. Translatable fields merge into the existing map, so sending only `{"title": {"ca": "..."}}` leaves Spanish and English intact. Authors may always edit their own unpublished pages. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PageWrite" } responses: "200": description: Page updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Pages] summary: Soft-delete a page responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/pages/{page}/restore: post: tags: [Pages] summary: Restore a soft-deleted page parameters: - name: page in: path required: true schema: { type: integer } responses: "200": description: Page restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } /v1/categories: get: tags: [Categories] summary: List categories parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: is_visible in: query schema: { type: boolean } - name: search in: query schema: { type: string } responses: "200": description: Paginated categories content: application/json: schema: { $ref: "#/components/schemas/CategoryCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Categories] summary: Create a category requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CategoryWrite" } responses: "201": description: Category created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Category" } "422": { $ref: "#/components/responses/ValidationError" } /v1/categories/{category}: parameters: - name: category in: path required: true schema: { type: integer } get: tags: [Categories] summary: Show a category responses: "200": description: The category content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Category" } patch: tags: [Categories] summary: Update a category requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CategoryWrite" } responses: "200": description: Category updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Category" } delete: tags: [Categories] summary: Soft-delete a category description: Refused with 403 while the category still holds pages. responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/users: get: tags: [Users] summary: List local users description: Requires `users.view_any`. parameters: - $ref: "#/components/parameters/PerPage" - name: search in: query schema: { type: string } - name: is_active in: query schema: { type: boolean } - name: role in: query schema: { type: string } responses: "200": description: Paginated users content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/User" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } "403": { $ref: "#/components/responses/Forbidden" } /v1/users/{user}: parameters: - name: user in: path required: true schema: { type: integer } get: tags: [Users] summary: Show a user description: Reading your own record is always permitted. responses: "200": description: The user content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/User" } patch: tags: [Users] summary: Update a user's local access description: | Only fields this application owns are writable. Name and email belong to the identity server and are refreshed on every sign-in, so change those at `oauth.pireon.dev` instead. Assigning roles additionally requires `roles.update`, and only a super admin may grant `super_admin`. requestBody: required: true content: application/json: schema: type: object properties: is_active: { type: boolean } locale: { type: string, enum: [es, en, ca], nullable: true } roles: type: array items: { type: string } example: [editor] responses: "200": description: User updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/User" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/roles: get: tags: [Roles] summary: List roles and their permissions responses: "200": description: All roles content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Role" } "403": { $ref: "#/components/responses/Forbidden" } /v1/roles/catalogue: get: tags: [Roles] summary: The full permission catalogue, grouped by resource description: Useful for building a role editor outside the panel. responses: "200": description: Permission catalogue content: application/json: schema: type: object properties: data: type: object additionalProperties: type: array items: { type: string } example: general: [panel.access] pages: [pages.view_any, pages.create, pages.publish] /v1/roles/{role}: get: tags: [Roles] summary: Show a role parameters: - name: role in: path required: true schema: { type: integer } responses: "200": description: The role content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Role" } components: securitySchemes: bearerAuth: type: http scheme: bearer description: | An access token issued by https://oauth.pireon.dev. Validated on each request against the identity server's `GET /api/user`, with the result cached briefly. ingestToken: type: apiKey in: header name: X-Ingest-Token description: | Shared secret held by the frontend, for the endpoints it calls on its own behalf rather than on a visitor's. In a header rather than the query string so it stays out of access logs. An unset secret disables those endpoints instead of opening them. parameters: Lang: name: lang in: query description: Language to resolve translatable fields into, overriding Accept-Language. schema: { type: string, enum: [es, en, ca] } With: name: with in: query description: Comma-separated extras. `translations` adds every language. schema: { type: string, example: translations } PreviewToken: name: X-Preview-Token in: header description: | Shared secret that lets the frontend render unpublished content. When valid, the `published` filter is skipped and the response is marked `no-store`. An unset secret never grants access. schema: { type: string } IngestToken: name: X-Ingest-Token in: header required: true description: Shared secret for the endpoints the frontend calls on its own behalf. schema: { type: string } PerPage: name: per_page in: query schema: { type: integer, default: 15, maximum: 100 } responses: Maintenance: description: | The public site is in maintenance. Returned by every `/public/v1` endpoint while the flag is on, with the notice already resolved to the request's language so the frontend can render a maintenance page from it directly. Never cached, and accompanied by `Retry-After`. The panel and the admin API are unaffected — this is deliberately not Laravel's own maintenance mode. headers: Retry-After: schema: { type: integer, example: 3600 } content: application/json: schema: type: object properties: message: { type: string, example: Estamos migrando el Gitea. Volvemos en un rato. } maintenance: { type: boolean, example: true } locale: { type: string, example: es } since: { type: string, format: date-time, nullable: true } until: { type: string, format: date-time, nullable: true } Unauthorized: description: Missing, expired, or revoked bearer token content: application/json: schema: type: object properties: message: { type: string, example: Unauthenticated. } Forbidden: description: The caller lacks the required local permission content: application/json: schema: type: object properties: message: { type: string, example: This action is unauthorized. } ValidationError: description: Validation failed content: application/json: schema: type: object properties: message: { type: string } errors: type: object additionalProperties: type: array items: { type: string } example: slug: ['El slug «inicio» ya está en uso en el idioma «es».'] SubmissionAccepted: description: | Stored, and the people who can read the inbox have been told. The body of the submission is never echoed back, so a guessed id cannot be used to read somebody else's message. Always `no-store`. headers: Cache-Control: schema: { type: string, example: "no-store, private" } content: application/json: schema: { $ref: "#/components/schemas/SubmissionReceipt" } schemas: Image: type: object description: | Everything needed to render an image well server-side. A path alone was never enough: without `width`/`height` the page reflows as images arrive, without `srcset` every visitor downloads the original, and without `alt` the page is not accessible. Degrades on purpose — an upload whose derived versions are not ready yet still carries a `url`, with the rest null, so the frontend renders a plain `` rather than a gap. properties: url: type: string format: uri description: Absolute, because the frontend is on another host. alt: type: string description: | Empty string when the image is decorative — that is what `alt=""` means — never absent. Follows the requested language. width: { type: integer, nullable: true } height: { type: integer, nullable: true } aspect_ratio: { type: number, nullable: true } mime: { type: string, nullable: true } placeholder: type: string nullable: true description: | Tiny blurred data URI to paint while the real image loads. srcset: type: object nullable: true additionalProperties: { type: string } description: | Keyed by format, ready to use verbatim — build a `` with AVIF first and WebP behind it. Only widths that actually exist are listed, and an image is never scaled up. example: webp: "https://admin.example/storage/projects/cover-400.webp 400w, https://admin.example/storage/projects/cover-800.webp 800w" ViewReport: type: object required: [type, slug] properties: type: type: string enum: [page, project, member, call, service] description: | The same vocabulary `/slugs` and the path configuration use, so a report can only ever name something this API serves. slug: type: string maxLength: 200 example: mi-web description: The slug in the language given below, not a full path. locale: type: string enum: [es, en, ca] description: | Which language was rendered. Falls back to the negotiated locale. Counted separately per language, because which language people read something in is most of what these figures are for. count: type: integer minimum: 1 maximum: 1000 default: 1 description: | For a frontend that batches rather than reporting each render. Capped: the frontend is trusted to count its own renders, not to invent an audience. MissingUrlReport: type: object required: [path] properties: path: type: string maxLength: 500 pattern: "^/" example: /proyectos/nombre-viejo description: | A path, not a URL. The frontend knows its own origin, and accepting a full address would let a caller file a 404 against somebody else's domain. Normalised on arrival: query strings and trailing slashes are stripped, so `/algo/` and `/algo?utm_source=x` are one address rather than three. locale: type: string enum: [es, en, ca] description: | Which language was being rendered. Falls back to the negotiated locale when absent, because it is half of the key that keeps one address in two languages as two separate problems — they need two different redirects. referrer: type: string format: uri maxLength: 500 nullable: true description: | Straight from the `Referer` header. The most useful field here: one on our own domain means something we publish links to a dead address, which the panel flags as an error rather than a warning. Submission: type: object required: [name, email, message, consent] properties: name: { type: string, minLength: 2, maxLength: 120 } email: { type: string, format: email, maxLength: 255 } message: type: string minLength: 20 maxLength: 5000 description: | The minimum length costs a real person nothing and rules out the one-word submissions that are never anything but noise. organisation: type: string maxLength: 160 nullable: true description: Required when `kind` is `request`. hours_per_week: type: integer minimum: 1 maximum: 40 nullable: true description: | Mirrors the call's own ask, so the two numbers can be compared without reading the message. links: type: object maxProperties: 5 additionalProperties: { type: string, format: uri } example: { github: "https://github.com/ada", web: "https://ada.example" } consent: type: boolean description: | Must be `true`. The sender is not a user of this system, so their agreement to have their details stored travels with the payload rather than being assumed of the frontend. website: type: string nullable: true description: | Honeypot — the field name comes from `SITE_HONEYPOT_FIELD`. Never render it visibly. A submission carrying it is filed as spam rather than dropped, and still answers 201, so a bot learns nothing and a mistakenly-tripped human is recoverable. SubmissionReceipt: type: object properties: data: type: object properties: id: { type: integer } kind: { type: string, enum: [contribute, request] } status: { type: string, example: new } duplicate: type: boolean description: True when this repeated an earlier submission. message: type: string description: Confirmation text, already in the visitor's language. example: Hemos recibido tu mensaje. Te contestamos en cuanto podamos. PublicProject: type: object properties: id: { type: integer } locale: { type: string, example: es } title: { type: string } slug: { type: string } tagline: { type: string, nullable: true } description: type: string nullable: true description: Detail endpoint only. decisions: type: string nullable: true description: | Why the technical choices were made. Detail endpoint only. postmortem: type: string nullable: true description: | What went wrong and what was learned. Detail endpoint only. meta_title: type: string nullable: true description: | What a search engine or a chat preview should show instead of the title. Detail endpoint only. meta_description: type: string nullable: true description: Detail endpoint only. status: { type: string, enum: [idea, active, paused, completed, archived] } is_featured: { type: boolean } needs_help: type: boolean description: Flagged directly, or carrying at least one open call. cover_image: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true gallery: type: array description: | Screenshots and before-and-after, in the order they were arranged in the panel. **Detail endpoint only** — a listing of twenty projects has no use for every screenshot of each one. items: allOf: - $ref: "#/components/schemas/Image" - type: object properties: caption: type: string nullable: true description: | Shown to everyone, unlike `alt`, which is read by whoever cannot see the image. "Before" and "after" are captions. demo_url: { type: string, nullable: true } client: allOf: [{ $ref: "#/components/schemas/PublicClient" }] nullable: true description: | The NGO or association the work serves. Used to be two free-text fields, which made the same organisation two different things. services: type: array items: { $ref: "#/components/schemas/PublicService" } testimonials: type: array items: { $ref: "#/components/schemas/PublicTestimonial" } talks: type: array items: { $ref: "#/components/schemas/PublicTalk" } started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: | When the record leaves the site. Enforced by the query itself, so it holds whether or not anything is running. Use it to expire a cache downstream at the same moment. updated_at: { type: string, format: date-time } technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } members: type: array items: { $ref: "#/components/schemas/PublicMember" } lead: { $ref: "#/components/schemas/PublicMember" } calls: type: array items: { $ref: "#/components/schemas/PublicCall" } repositories: type: array items: { $ref: "#/components/schemas/PublicRepository" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true, example: /proyectos/mi-web } PublicMember: type: object properties: id: { type: integer } name: { type: string } slug: { type: string } headline: { type: string, nullable: true } bio: { type: string, nullable: true } avatar: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true github_username: { type: string, nullable: true } links: type: object additionalProperties: { type: string } example: { github: "https://github.com/ada", website: "https://ada.dev" } is_founder: { type: boolean } is_current: type: boolean description: False for people who have left; their work is kept. joined_at: { type: string, format: date, nullable: true } left_at: { type: string, format: date, nullable: true } role: type: string nullable: true description: Their role on a project, when nested under one. technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } projects: type: array items: { $ref: "#/components/schemas/PublicProject" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, example: /equipo/ada } PublicCall: type: object description: | A call for contributors. The commitment fields are the point: time is the scarce resource in a volunteer collective, and stating it plainly is what lets someone decide. properties: id: { type: integer } locale: { type: string } title: { type: string } slug: { type: string } description: { type: string, nullable: true } what_you_get: type: string nullable: true description: The explicit exchange offered for unpaid work. status: { type: string, enum: [open, filled, closed] } hours_per_week: { type: integer, nullable: true, example: 4 } duration_weeks: { type: integer, nullable: true, example: 8 } total_hours: { type: integer, nullable: true, example: 32 } seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } closes_at: { type: string, format: date, nullable: true } project: { $ref: "#/components/schemas/PublicProject" } technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } PublicTechnology: type: object properties: id: { type: integer } name: type: string description: Not translated — technology names are proper nouns. example: Laravel slug: { type: string } category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } color: { type: string, nullable: true } icon: { type: string, nullable: true } logo: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true description: | The technology's own logo. Its `alt` is the technology's name — there is nothing to write and nothing to get wrong. website: { type: string, nullable: true } ring: type: string nullable: true enum: [adopt, trial, assess, hold] description: Radar placement. Null for technologies used but not placed. description: { type: string, nullable: true } radar_note: type: string nullable: true description: Why it sits in that ring. projects_count: { type: integer } members_count: { type: integer } PublicRepository: type: object description: Mirrored from the git provider and refreshed hourly. properties: id: { type: integer } provider: { type: string, enum: [github, gitlab, gitea] } full_name: { type: string, example: colectivo/web } url: { type: string, nullable: true } description: { type: string, nullable: true } primary_language: { type: string, nullable: true } languages: type: object additionalProperties: { type: integer } description: Bytes of code per language. topics: type: array items: { type: string } license: { type: string, nullable: true } stars: { type: integer } forks: { type: integer } contributors_count: { type: integer } open_issues_count: { type: integer } is_archived: { type: boolean } is_dormant: type: boolean description: | Nothing pushed for six months. Lets the frontend avoid presenting dead work as live. pushed_at: { type: string, format: date-time, nullable: true } synced_at: { type: string, format: date-time, nullable: true } good_first_issues: type: array items: { $ref: "#/components/schemas/PublicIssue" } PublicIssue: type: object properties: number: { type: integer } title: { type: string } url: { type: string } labels: type: array items: { type: string } is_good_first_issue: { type: boolean } is_help_wanted: { type: boolean } is_available: type: boolean description: Nobody has claimed it. A claimed issue is not an invitation. comments: { type: integer } created_at: { type: string, format: date-time, nullable: true } repository: type: object properties: full_name: { type: string } url: { type: string, nullable: true } primary_language: { type: string, nullable: true } project: type: object nullable: true properties: title: { type: string } slug: { type: string } PublicService: type: object properties: id: { type: integer } locale: { type: string } title: { type: string } slug: { type: string } tagline: { type: string, nullable: true } description: type: string nullable: true description: Detail endpoint only. meta_title: type: string nullable: true description: | What a search engine or a chat preview should show instead of the title. Detail endpoint only. meta_description: type: string nullable: true description: Detail endpoint only. icon: type: string nullable: true description: A Heroicons name, e.g. `heroicon-o-code-bracket`. is_featured: { type: boolean } position: { type: integer } updated_at: type: string format: date-time nullable: true description: | So a cache downstream can tell whether its copy is still the current one. projects: type: array description: The published work that backs the offer. Detail endpoint only. items: { $ref: "#/components/schemas/PublicProject" } technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } testimonials: type: array items: { $ref: "#/components/schemas/PublicTestimonial" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } PublicClient: type: object properties: id: { type: integer } name: type: string description: A proper noun, so it is the same in every language. slug: { type: string } sector: { type: string, nullable: true } url: { type: string, nullable: true } logo: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true description: Its alt text is the organisation's name. projects_count: { type: integer } PublicTestimonial: type: object properties: id: { type: integer } quote: { type: string } original_locale: type: string description: The language it was actually said in. is_original: type: boolean description: | False when the quote you are reading was translated by us. Say so — "translated from Spanish" — rather than implying they phrased it that way. author: type: object properties: name: { type: string } role: { type: string, nullable: true } avatar: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true given_at: { type: string, format: date, nullable: true } client: allOf: [{ $ref: "#/components/schemas/PublicClient" }] nullable: true PublicTalk: type: object properties: id: { type: integer } title: type: string description: | Not translated: the name of a talk given once, in one language. language: { type: string } summary: { type: string, nullable: true } event: { type: string } city: { type: string, nullable: true } held_at: { type: string, format: date, nullable: true } video_url: { type: string, nullable: true } slides_url: { type: string, nullable: true } speaker: allOf: [{ $ref: "#/components/schemas/PublicMember" }] nullable: true PublicPage: type: object properties: id: { type: integer } locale: { type: string } title: { type: string, nullable: true } slug: { type: string, nullable: true } excerpt: { type: string, nullable: true } body: { type: string, nullable: true } meta_title: { type: string, nullable: true } meta_description: { type: string, nullable: true } status: { type: string } is_featured: { type: boolean } featured_image: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: | When the record leaves the site. Enforced by the query itself, so it holds whether or not anything is running. Use it to expire a cache downstream at the same moment. updated_at: { type: string, format: date-time } category: { $ref: "#/components/schemas/PublicCategory" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } PublicCategory: type: object properties: id: { type: integer } name: { type: string, nullable: true } slug: { type: string, nullable: true } description: { type: string, nullable: true } color: { type: string, nullable: true } position: { type: integer } pages_count: { type: integer } Alternates: type: object description: | The same record's path in each language that has a translation. Feed these straight into ``. Languages with no translation are absent, so no advertised URL ever 404s. additionalProperties: { type: string } example: es: /proyectos/mi-web en: /en/projects/my-web ca: /ca/projectes/el-meu-web TranslatedField: description: | Either a plain string (applies to the active language) or an object keyed by locale. oneOf: - type: string - type: object additionalProperties: { type: string, nullable: true } example: { es: "Inicio", en: "Home", ca: "Inici" } Page: type: object properties: id: { type: integer } locale: type: string description: The language the fields below were resolved into. example: es title: { type: string, nullable: true } slug: { type: string, nullable: true } excerpt: { type: string, nullable: true } body: { type: string, nullable: true } meta_title: { type: string, nullable: true } meta_description: { type: string, nullable: true } status: { type: string, enum: [draft, review, published, archived] } status_label: { type: string, description: Translated status name. } is_featured: { type: boolean } featured_image: type: string nullable: true description: | The stored path, because this is the editing API and it is what a client PATCHes back. What was derived from it travels alongside. featured_image_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: | When the record leaves the site. Enforced by the query itself, so it holds whether or not anything is running. Use it to expire a cache downstream at the same moment. created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } category: { $ref: "#/components/schemas/Category" } author: { $ref: "#/components/schemas/User" } featured_image_alt: type: string nullable: true description: | What the image shows, resolved into the language above — unlike `PageWrite`'s field of the same name, which carries every language at once. translations: type: object nullable: true description: Present only when `?with=translations` was passed. additionalProperties: type: object additionalProperties: { type: string } PageWrite: type: object required: [title, slug] properties: title: { $ref: "#/components/schemas/TranslatedField" } slug: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: Unique per language. The same slug may be reused across languages. excerpt: { $ref: "#/components/schemas/TranslatedField" } body: { $ref: "#/components/schemas/TranslatedField" } meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } category_id: { type: integer, nullable: true } status: { type: string, enum: [draft, review, published, archived] } featured_image: type: string nullable: true description: Path returned by the upload, e.g. "pages/hero.jpg". featured_image_alt: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: | What the image shows, per language. Belongs to the record rather than to the file: the same image can need a different description depending on where it is used. is_featured: { type: boolean } published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: Must be after `published_at`. When it arrives the record leaves the site on its own. Category: type: object properties: id: { type: integer } locale: { type: string, example: es } name: { type: string, nullable: true } slug: { type: string, nullable: true } description: { type: string, nullable: true } color: { type: string, nullable: true, example: "#4f46e5" } position: { type: integer } is_visible: { type: boolean } pages_count: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } translations: type: object nullable: true description: Present only when `?with=translations` was passed. additionalProperties: type: object additionalProperties: { type: string } CategoryWrite: type: object required: [name, slug] properties: name: { $ref: "#/components/schemas/TranslatedField" } slug: { $ref: "#/components/schemas/TranslatedField" } description: { $ref: "#/components/schemas/TranslatedField" } color: { type: string, pattern: '^#[0-9a-fA-F]{6}$' } position: { type: integer, minimum: 0 } is_visible: { type: boolean } User: type: object properties: id: { type: integer, description: Local id. } oauth_id: { type: integer, nullable: true, description: This person's id on the identity server. } name: { type: string } email: { type: string, format: email } avatar_url: { type: string, nullable: true } locale: { type: string, nullable: true, enum: [es, en, ca] } is_active: { type: boolean } last_login_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } roles: type: array items: { type: string } description: Local roles — what this person may do in this app. example: [editor] permissions: type: array items: { type: string } nullable: true description: Effective local permissions. Returned by `/v1/me` only. remote_roles: type: array items: { type: string } description: | Roles reported by the identity server. Informational only; they grant nothing here. Role: type: object properties: id: { type: integer } name: { type: string, example: editor } guard_name: { type: string, example: web } users_count: { type: integer } permissions: type: array items: { type: string } created_at: { type: string, format: date-time } PasswordGrantRequest: type: object required: [grant_type, username, password] properties: grant_type: { type: string, enum: [password] } username: { type: string, format: email } password: { type: string, format: password } scope: { type: string, example: "account:read" } RefreshGrantRequest: type: object required: [grant_type, refresh_token] properties: grant_type: { type: string, enum: [refresh_token] } refresh_token: { type: string } TokenResponse: type: object properties: access_token: { type: string } refresh_token: { type: string, nullable: true } token_type: { type: string, example: Bearer } expires_at: { type: string, format: date-time, nullable: true } scopes: type: array items: { type: string } user: type: object properties: id: { type: integer } name: { type: string } email: { type: string } roles: type: array items: { type: string } permissions: type: array items: { type: string } PaginationMeta: type: object description: | Laravel's own paginator shape, sent as-is — nothing on this side trims it down, so this describes exactly what `->paginate()` sends rather than a curated subset of it. properties: current_page: { type: integer } from: { type: integer, nullable: true, description: Null on an empty page. } last_page: { type: integer } links: type: array description: One entry per page number shown by a "1 2 3 … 9" control, plus prev/next. items: type: object properties: url: { type: string, nullable: true } label: { type: string, description: "Already translated — e.g. «Anterior», not «Previous»." } page: { type: integer, nullable: true } active: { type: boolean } path: { type: string } per_page: { type: integer } to: { type: integer, nullable: true, description: Null on an empty page. } total: { type: integer } PaginationLinks: type: object description: The four addresses a client actually navigates with, ready to use verbatim. properties: first: { type: string } last: { type: string } prev: { type: string, nullable: true } next: { type: string, nullable: true } PageCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Page" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } CategoryCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Category" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" }