POST /api/v1/media stores an image where the panel keeps a page's and answers with the path to send as `featured_image`. Without it the MCP server could create a page but never give it a picture. The file goes through Media::for(), so it is processed, served and pruned like one uploaded in the panel. Two ways in, one per request: a multipart `file`, or JSON with a `url` the API downloads itself. JPEG, PNG, WebP, GIF or AVIF only, decided from the bytes; no SVG, which is a document that can carry script. The URL is fetched from inside the stack's network, so RemoteImage guards it: HTTPS only, every resolved address must be public, the connection is pinned to the checked address with CURLOPT_RESOLVE so a changing DNS answer cannot swap in a private one, redirects are not followed, and the body is read to a ceiling whatever its headers say. Uploading needs permission to write pages. MediaPolicy::create stays false, so the panel gains no free-standing "new file" button. In the MCP server a multipart binary field becomes `<field>_path` over stdio: the server reads the file from the caller's disk and sends it as multipart, instead of the model pushing base64 through its context. Over HTTP the server's disk is not the caller's, so only `body.url` is offered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2366 lines
83 KiB
YAML
2366 lines
83 KiB
YAML
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: Media
|
|
description: Images uploaded for a record to point at
|
|
- 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/media:
|
|
post:
|
|
tags: [Media]
|
|
summary: Upload an image
|
|
description: |
|
|
Stores an image and answers with the `path` a record points at — send
|
|
it as a page's `featured_image`. Two ways in, exactly one per request:
|
|
|
|
- **`multipart/form-data`** with the image as `file`.
|
|
- **`application/json`** with a `url`, which the API downloads itself.
|
|
HTTPS only, from a public address — anything resolving to a private,
|
|
reserved or loopback address is refused — and redirects are not
|
|
followed: send the image's final address.
|
|
|
|
JPEG, PNG, WebP, GIF or AVIF, decided from the file's bytes rather than
|
|
its name or declared type; SVG is not accepted here. 20 MB at most.
|
|
|
|
The smaller versions and the placeholder are derived afterwards, on the
|
|
queue: `processed` says whether that has happened, and the image serves
|
|
either way. Requires permission to create or edit pages.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
multipart/form-data:
|
|
schema:
|
|
type: object
|
|
required: [file]
|
|
properties:
|
|
file:
|
|
type: string
|
|
format: binary
|
|
description: The image.
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [url]
|
|
properties:
|
|
url:
|
|
type: string
|
|
format: uri
|
|
description: An `https://` address the API downloads the image from.
|
|
example: https://images.example.org/hero.jpg
|
|
responses:
|
|
"201":
|
|
description: Image stored
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
data: { $ref: "#/components/schemas/Media" }
|
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
"422": { $ref: "#/components/responses/ValidationError" }
|
|
|
|
/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 `<img>` 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 `<picture>` 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 `<link rel="alternate" hreflang>`. 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.
|
|
|
|
Media:
|
|
type: object
|
|
properties:
|
|
path:
|
|
type: string
|
|
description: What a record stores, e.g. as a page's `featured_image`.
|
|
example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg
|
|
url: { type: string, format: uri, description: Absolute. }
|
|
mime: { type: string, nullable: true, example: image/jpeg }
|
|
size: { type: integer, nullable: true, description: Bytes. }
|
|
width: { type: integer, nullable: true }
|
|
height: { type: integer, nullable: true }
|
|
processed:
|
|
type: boolean
|
|
description: |
|
|
Whether the queue has derived the smaller versions and the
|
|
placeholder yet. The image serves either way.
|
|
|
|
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" }
|