Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ yarn-error.log
.DS_Store

# Scribe generated API docs - regenerated via `php artisan scribe:generate`
# (doc content lives in config/scribe.php + controller annotations). Output is
# static HTML built into the prod image (Docker/Dockerfile.prod `docs` stage).
# (doc content lives in config/scribe.php + controller annotations). laravel-type
# output: the Blade docs view + its assets, baked into the prod image at build time
# (Docker/Dockerfile.prod) and regenerated on every dev `up`.
/.scribe
/public/docs
/resources/views/scribe
/public/vendor/scribe
/storage/app/scribe
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,13 @@ All API responses go through Laravel API Resources at `app/Http/Resources/V1/`.

### API Documentation (Scribe)

The `/api/v1` REST API is documented with [Scribe](https://scribe.knuckles.wtf) (`knuckleswtf/scribe`, a dev dependency). Docs are generated as **static HTML** into `public/docs/` and served **directly by the web server** (nginx in prod) as static files at **`/docs`** — there is no PHP `/docs` route and no `scribe` named route. An OpenAPI spec and Postman collection are emitted alongside under `public/docs/`. An "API Reference" link is in the footer of both the `app` and `landing` layouts (`url('/docs')`).
The `/api/v1` REST API is documented with [Scribe](https://scribe.knuckles.wtf) (`knuckleswtf/scribe`, a **runtime** dependency in `require`). Docs are generated as a **`laravel`-type Blade view** (`resources/views/scribe/index.blade.php` + assets under `public/vendor/scribe/`) and served by a Laravel route at **`/docs`** — `Route::view('/docs', 'scribe.index')->name('scribe')` in `routes/web.php`. Because the page is rendered at request time, its **base URL resolves to the live `APP_URL`** on each request (fixing the old static-HTML bug where the build-time `localhost` was baked in). An "API Reference" link is in the footer of both the `app` and `landing` layouts (`url('/docs')`).

- **Config** — `config/scribe.php`: `type => 'static'` (output `public/docs`), `routes.match.prefixes => ['api/*']` (only API routes are documented), bearer-token auth (`auth.default => true`, so every endpoint requires a Sanctum token except those marked `@unauthenticated` — currently only `GET /api/v1/info`). The live `ResponseCalls` strategy is removed so generation is deterministic and never touches the DB. The whole config is guarded by `if (! class_exists(AuthIn::class)) return [];` — Scribe is dev-only and absent from the `--no-dev` production runtime image, so this stops `config:cache` (run by `php artisan optimize` on every container start) from fataling on the missing `Knuckles\Scribe\*` classes.
- **Config** — `config/scribe.php`: `type => 'laravel'`; `base_url => '{{ config("app.url") }}'` (the dynamic-string form Scribe renders at request time, so the docs heading always shows this instance's URL); `laravel.add_routes => false` (routing is manual — see above — so only the `/docs` page is exposed, not the `.postman`/`.openapi` spec routes); `postman.enabled`/`openapi.enabled => false` (the machine-readable specs are dropped: they are raw static files that can't use the dynamic base URL, and their laravel-type output dir `storage/app/scribe` is hidden by the prod storage volume). `routes.match.prefixes => ['api/*']` (only API routes are documented); bearer-token auth (`auth.default => true`, so every endpoint needs a Sanctum token except those marked `@unauthenticated` — currently only `GET /api/v1/info`). The live `ResponseCalls` strategy is removed so generation is deterministic and never touches the DB. (Scribe is now always installed, so the old `if (! class_exists(AuthIn::class)) return [];` config guard is gone.)
- **Doc content lives in source** — example responses, request-body fields, groups and auth flags are Scribe docblock annotations (`@group`, `@authenticated`/`@unauthenticated`, `@urlParam`, `@queryParam`, `@bodyParam`, `@response`) on the `app/Http/Controllers/Api/V1/*` controllers. `@bodyParam` values mirror the matching `Store*Request` rules. Keep them in sync when the API changes. The `GET /api/v1/user` endpoint was extracted from an inline route closure into `UserController` so it could be annotated.
- **Generation** — `php artisan scribe:generate` writes static HTML to `public/docs/`; the output plus intermediates (`.scribe/`, `storage/app/scribe/`) are **gitignored**, so it must be (re)generated wherever the app is deployed or `/docs` 404s.
- **Production** — baked into the image at build time: `Docker/Dockerfile.prod` runs `scribe:generate` in a dedicated `docs` stage (which installs the dev deps that carry Scribe) and `COPY --from=docs`es `public/docs` into the Scribe-free runtime image. nginx serves it statically (`index index.php index.html` in `Docker/nginx.conf` resolves `/docs` → `public/docs/index.html`). No deploy step needed.
- **Dev** — the one-shot `docs` service in `Docker/docker-compose.yml` runs `scribe:generate` into the mounted repo before `app` serves; `php artisan serve` serves the static files, and `routes/web.php` has a `Route::redirect('/docs', '/docs/index.html')` so `/docs` resolves (the built-in dev server won't auto-serve a directory index). Regenerate manually with `docker exec yaams-dev-app php artisan scribe:generate` after editing annotations.
- **Generation** — `php artisan scribe:generate` writes the Blade view + assets; the output plus intermediates (`resources/views/scribe/`, `public/vendor/scribe/`, `.scribe/`, `storage/app/scribe/`) are **gitignored**, so it must be (re)generated wherever the app is deployed or `/docs` 500s on the missing view.
- **Production** — baked into the image at build time: `Docker/Dockerfile.prod` runs `scribe:generate` in the runtime stage (Scribe is in the runtime `vendor`, no separate docs/dev-vendor stage). To make the copy-paste example snippets and the "Try It Out" host dynamic too (Scribe pre-renders `base_url` into those, which would otherwise freeze to the build-time URL), it generates against a sentinel base URL (`APP_URL=https://scribe-base-url.invalid`) and then `sed`-replaces that sentinel with `{{ config('app.url') }}` directly in the generated view — the request-time Blade render then resolves every occurrence to the real `APP_URL`. An arbitrary runtime UID cannot write `public/`/`resources/`, which is why generation happens at build (as root), not at container start.
- **Dev** — the one-shot `docs` service in `Docker/docker-compose.yml` runs `scribe:generate` into the mounted repo before `app` serves; `php artisan serve` then serves `/docs` via the Laravel route. Dev needs no sentinel/`sed` — it generates against the dev `APP_URL` (`http://localhost:8000`), which is exactly where the dev server runs. Regenerate manually with `docker exec yaams-dev-app php artisan scribe:generate` after editing annotations.

### Prometheus Metrics

Expand Down
75 changes: 30 additions & 45 deletions Docker/Dockerfile.prod
Original file line number Diff line number Diff line change
Expand Up @@ -36,47 +36,6 @@ RUN --mount=type=cache,target=/tmp/composer-cache \
--optimize-autoloader \
--ignore-platform-req=ext-gd

# ---------------------------------------------------------------------------
# Stage 1b - Composer dependencies WITH dev packages (only for doc generation)
# ---------------------------------------------------------------------------
# Scribe (knuckleswtf/scribe) is a dev dependency, so it is deliberately absent
# from the runtime `vendor` stage above. This throwaway stage installs it just
# so the `docs` stage below can run `artisan scribe:generate`. `--ignore-platform-reqs`
# because the composer image lacks the app's PHP extensions - we only need the files.
FROM composer:2 AS vendor-dev
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache \
composer install \
--no-scripts \
--no-interaction \
--prefer-dist \
--ignore-platform-reqs

# ---------------------------------------------------------------------------
# Stage 1c - Generate the static API docs (public/docs) with Scribe
# ---------------------------------------------------------------------------
# Runs `scribe:generate` (type=static) to emit public/docs/, copied into the
# runtime image below and served by nginx. App boot here is DB-less: the live
# ResponseCalls strategy is removed (config/scribe.php) and AppServiceProvider's
# only boot-time DB access is wrapped in rescue(), so no database is needed.
FROM php:8.3-cli-alpine AS docs
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_mysql intl
WORKDIR /app
COPY --from=vendor-dev /app/vendor ./vendor
COPY . .
# Throwaway env for this ephemeral build stage only (never shipped) - set inline on
# the RUN so it doesn't linger as an image ENV (and doesn't trip secret-in-ENV linters).
RUN APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \
APP_ENV=production \
CACHE_STORE=array \
SESSION_DRIVER=array \
QUEUE_CONNECTION=sync \
MAIL_MAILER=log \
php artisan scribe:generate

# ---------------------------------------------------------------------------
# Stage 2 - Runtime (nginx + PHP-FPM on Alpine)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -116,13 +75,39 @@ RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY Docker/nginx.conf /etc/nginx/nginx.conf
COPY Docker/supervisord.conf /etc/supervisord.conf

# App source + resolved vendor from stage 1.
# App source + resolved vendor from stage 1. Scribe is now a runtime dependency
# (composer `require`), so it is present in the vendor stage.
COPY . /app
COPY --from=vendor /app/vendor /app/vendor

# Static API docs (Scribe), pre-generated in the `docs` stage; served by nginx.
# The runtime image itself has no Scribe (dev-only dependency).
COPY --from=docs /app/public/docs /app/public/docs
# Generate the Scribe API docs (laravel type) into the image: the Blade view at
# resources/views/scribe/ + assets under public/vendor/scribe/, served at /docs by a
# Laravel route. Runs as root so the baked-in output is readable by any runtime UID
# (an unprivileged UID could not write these paths at container start).
#
# Making the docs base URL dynamic: the docs page heading uses the base_url config
# verbatim ('{{ config("app.url") }}'), so it already resolves at request time. But
# Scribe pre-renders that string when baking the copy-paste example snippets and the
# "Try It Out" host, so those would otherwise freeze to whatever APP_URL was at build
# time (unknown here - one image serves any instance). So generate against a sentinel
# base URL, then replace it with the Blade expression directly in the view (raw, after
# Scribe's HTML-escaping) - the request-time Blade render then resolves every occurrence
# to this instance's real APP_URL. Single quotes inside config() keep it valid once
# baked into the JS/HTML without further escaping.
#
# Throwaway env for this build stage only (never shipped) - set inline on the RUN so it
# doesn't linger as an image ENV. DB-less: ResponseCalls is removed (config/scribe.php)
# and AppServiceProvider's only boot-time DB access is rescue()-wrapped.
RUN APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \
APP_ENV=production \
APP_URL=https://scribe-base-url.invalid \
CACHE_STORE=array \
SESSION_DRIVER=array \
QUEUE_CONNECTION=sync \
MAIL_MAILER=log \
php artisan scribe:generate \
&& sed -i "s#https://scribe-base-url.invalid#{{ config('app.url') }}#g" \
resources/views/scribe/index.blade.php

# Storage + cache must be writable by the runtime user, and nginx needs its
# lib dir writable too. Own them by the root group (GID 0) with group perms
Expand Down
7 changes: 4 additions & 3 deletions Docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ services:
- "yaams"
restart: "no"

# One-shot: generates the static Scribe API docs into public/docs in the mounted
# repo before the app starts, so a fresh dev stack never 404s the docs page. The
# dev app (php artisan serve) serves them at /docs via the redirect in routes/web.php.
# One-shot: generates the Scribe API docs (laravel type - a Blade view at
# resources/views/scribe + assets under public/vendor/scribe) into the mounted repo
# before the app starts, so a fresh dev stack never 404s the docs page. The dev app
# (php artisan serve) serves them at /docs via the route Scribe registers.
# Output is gitignored and deterministic (no DB needed), so it is safe to re-run on
# every `up`. In production the docs are baked into the image instead (Dockerfile.prod).
docs:
Expand Down
7 changes: 4 additions & 3 deletions Docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ http {
absolute_redirect off;

root /app/public;
# index.html lets the pre-generated static Scribe docs resolve: /docs ->
# /docs/ -> public/docs/index.html (assets are served by try_files below).
index index.php index.html;
# /docs is a Laravel route (Scribe laravel-type Blade view), so it falls through
# the `location /` try_files to index.php; its assets under public/vendor/scribe/
# are served statically by try_files $uri.
index index.php;

location / {
try_files $uri $uri/ /index.php?$query_string;
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"php": "^8.2",
"ext-gd": "*",
"intervention/image": "^3.11",
"knuckleswtf/scribe": "^5.11",
"laravel/fortify": "^1.37",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
Expand All @@ -19,7 +20,6 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"knuckleswtf/scribe": "^5.11",
"laravel/pail": "^1.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.29",
Expand Down
Loading
Loading