Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2245 lines
78 KiB
YAML
2245 lines
78 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: 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. 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" }
|
|
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" }
|
|
translations:
|
|
type: object
|
|
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 }
|
|
|
|
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 }
|
|
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
|
|
properties:
|
|
current_page: { type: integer }
|
|
last_page: { type: integer }
|
|
per_page: { type: integer }
|
|
total: { type: integer }
|
|
|
|
PageCollection:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Page" }
|
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
|
|
|
CategoryCollection:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Category" }
|
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|