feat(api): generate OpenAPI spec with Scramble for api-docs.invoiceshelf.com (#685)

Auto-generate an OpenAPI 3.1 spec from the v1 API's FormRequests and Resources
(no annotations) for publishing at api-docs.invoiceshelf.com as a static
Swagger UI site.

- config/scramble.php: scope to api/v1, version from version.md, clean
  placeholder server, export to public/openapi.json
- ScrambleServiceProvider: advertise Bearer (Sanctum) auth; add the required
  `company` tenancy header only to routes using the `company` middleware
- OpenApiDocumentationTest: assert spec shape, auth scheme, company-header gating
- .github/workflows/openapi.yml: export + commit spec on release, notify the
  api-docs site to rebuild
- public/openapi.json: generated seed spec (184 paths)
- dedoc/scramble added as a dev-only dependency

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-06-13 15:51:54 +02:00
committed by GitHub
parent 421c385fa7
commit 8d929ec09d
8 changed files with 31750 additions and 1 deletions

84
.github/workflows/openapi.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Export OpenAPI Spec
# Regenerates public/openapi.json with Scramble and notifies the api-docs site
# (api-docs.invoiceshelf.com) to rebuild. Runs on every published release — so the
# committed spec matches released code — and can be triggered manually.
#
# v3 only: this lives on the 3.x branch. The api-docs site documents the v3 API.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
export:
name: Export & publish OpenAPI document
runs-on: ubuntu-latest
env:
extensions: bcmath, curl, dom, gd, json, libxml, mbstring, pcntl, pdo, pdo_sqlite, zip
steps:
- name: Checkout (release branch)
uses: actions/checkout@v6
with:
# On a release, target_commitish is the branch the tag was cut from, so
# the spec is committed back to a branch rather than a detached tag.
ref: ${{ github.event.release.target_commitish || github.ref_name }}
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
extensions: ${{ env.extensions }}
coverage: none
tools: composer
- name: Install Composer dependencies (incl. dev — Scramble is dev-only)
uses: ramsey/composer-install@4.0.0
- name: Prepare app environment
run: |
cp -n .env.example .env || true
php artisan key:generate
- name: Migrate a throwaway SQLite database
# Scramble introspects the schema to build response shapes, so the tables
# must exist. Data is irrelevant — schema only.
run: |
mkdir -p database
: > database/openapi.sqlite
php artisan migrate --force --no-interaction
env:
DB_CONNECTION: sqlite
DB_DATABASE: ${{ github.workspace }}/database/openapi.sqlite
- name: Export OpenAPI document
run: php artisan scramble:export --path=public/openapi.json
env:
DB_CONNECTION: sqlite
DB_DATABASE: ${{ github.workspace }}/database/openapi.sqlite
- name: Commit updated spec
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "docs: regenerate OpenAPI spec [skip ci]"
file_pattern: public/openapi.json
commit_user_name: "GitHub Actions"
commit_user_email: "actions@github.com"
- name: Notify api-docs site to rebuild
continue-on-error: true
env:
PAT: ${{ secrets.API_DOCS_DISPATCH_PAT }}
run: |
if [ -z "$PAT" ]; then
echo "No API_DOCS_DISPATCH_PAT secret set; skipping rebuild notification."
exit 0
fi
curl -sf -X POST \
-H "Authorization: token $PAT" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/InvoiceShelf/api-docs/dispatches \
-d '{"event_type":"spec-updated"}'

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Providers;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\Generator\Parameter;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\RouteInfo;
use Illuminate\Support\ServiceProvider;
/**
* Configures Scramble's OpenAPI generation for the InvoiceShelf API.
*
* Scramble is a dev-only dependency used to generate the spec in CI/local (see
* `php artisan scramble:export`); it is absent in production (composer install
* --no-dev). Every reference to it is therefore guarded by class_exists().
*/
class ScrambleServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (! class_exists(Scramble::class)) {
return;
}
Scramble::configure()
->withDocumentTransformers($this->documentTransformer(...))
->withOperationTransformers($this->operationTransformer(...));
}
/**
* Advertise Bearer (Sanctum personal access token) auth as the global
* security scheme. The customer-portal guard (auth:customer) is session-based
* and not part of the token API surface, so it is intentionally not exposed.
*/
protected function documentTransformer(OpenApi $openApi): void
{
$openApi->secure(SecurityScheme::http('bearer'));
}
/**
* Add the multi-tenancy `company` header to every company-scoped operation.
* CompanyMiddleware (alias `company`) resolves the active tenant from this
* header, so only routes that actually run it get the parameter — auth, ping,
* and installation endpoints stay clean.
*/
protected function operationTransformer(Operation $operation, RouteInfo $routeInfo): void
{
if (! in_array('company', $routeInfo->route->gatherMiddleware(), true)) {
return;
}
$operation->addParameters([
Parameter::make('company', 'header')
->description('ID of the company the request operates on (multi-tenancy).')
->setSchema(Schema::fromType(new StringType))
->required(true),
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Providers\DriverRegistryProvider;
use App\Providers\DropboxServiceProvider;
use App\Providers\PdfServiceProvider;
use App\Providers\RouteServiceProvider;
use App\Providers\ScrambleServiceProvider;
use App\Providers\ViewServiceProvider;
use App\Support\Hashids\HashidsServiceProvider;
@@ -20,4 +21,5 @@ return [
DriverRegistryProvider::class,
AiServiceProvider::class,
AppConfigProvider::class,
ScrambleServiceProvider::class,
];

View File

@@ -35,6 +35,7 @@
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.5",
"dedoc/scramble": "^0.13.27",
"fakerphp/faker": "^1.23",
"jasonmccreary/laravel-test-assertions": "^2.9",
"laravel/pint": "^1.13",

82
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "56df267c2bb3603a8093fb53d6cf137c",
"content-hash": "0330848b7c96620b5ee47fcb585f719c",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -9531,6 +9531,86 @@
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "dedoc/scramble",
"version": "v0.13.27",
"source": {
"type": "git",
"url": "https://github.com/dedoc/scramble.git",
"reference": "5b067b1168b4092ee10b320469a09823610f48b1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/5b067b1168b4092ee10b320469a09823610f48b1",
"reference": "5b067b1168b4092ee10b320469a09823610f48b1",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"myclabs/deep-copy": "^1.12",
"nikic/php-parser": "^5.0",
"php": "^8.1",
"phpstan/phpdoc-parser": "^1.0|^2.0",
"spatie/laravel-package-tools": "^1.9.2"
},
"require-dev": {
"larastan/larastan": "^3.3",
"laravel/pint": "^v1.1.0",
"nunomaduro/collision": "^7.0|^8.0",
"orchestra/testbench": "^8.0|^9.0|^10.0|^11.0",
"pestphp/pest": "^2.34|^3.7|^4.4",
"pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5|^11.5.3|^12.5.12",
"spatie/laravel-permission": "^6.10|^7.2",
"spatie/pest-plugin-snapshots": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Dedoc\\Scramble\\ScrambleServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Dedoc\\Scramble\\": "src",
"Dedoc\\Scramble\\Database\\Factories\\": "database/factories"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Roman Lytvynenko",
"email": "litvinenko95@gmail.com",
"role": "Developer"
}
],
"description": "Automatic generation of API documentation for Laravel applications.",
"homepage": "https://github.com/dedoc/scramble",
"keywords": [
"documentation",
"laravel",
"openapi"
],
"support": {
"issues": "https://github.com/dedoc/scramble/issues",
"source": "https://github.com/dedoc/scramble/tree/v0.13.27"
},
"funding": [
{
"url": "https://github.com/romalytvynenko",
"type": "github"
}
],
"time": "2026-06-12T12:41:44+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",

187
config/scramble.php Normal file
View File

@@ -0,0 +1,187 @@
<?php
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [
/*
* Which routes to document. String or array form; use Scramble::routes() for custom selection.
*
* 'api_path' => [
* 'include' => 'api',
* 'exclude' => ['api/internal'],
* ],
*
* Without *, patterns match path segments (api matches api and api/users, not apiary).
* With *, Str::is is used (e.g. api/v*).
*
* One static include → default server is /{include} and paths are stripped (/users).
* Multiple includes or wildcards → server defaults to / and paths stay full (/api/users).
* Override with `servers`, or use Scramble::registerApi() for separate bases.
*/
'api_path' => 'api/v1',
/*
* Your API domain. By default, app domain is used. This is also a part of the default API routes
* matcher, so when implementing your own, make sure you use this config if needed.
*/
'api_domain' => null,
/*
* The path where your OpenAPI specification will be exported (relative to the
* project root). Committed to the repo by CI so the api-docs.invoiceshelf.com
* build can fetch it; also servable directly from this instance at /openapi.json.
*/
'export_path' => 'public/openapi.json',
/*
* Cache configuration for the generated OpenAPI document.
*
* Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it.
*/
'cache' => [
'key' => 'scramble.openapi',
'store' => 'file',
],
'info' => [
/*
* API version. Defaults to the value in the repo's version.md so the
* generated spec self-labels (e.g. 3.0.0-alpha.1 on v3.0, 2.x on master).
*/
'version' => env('API_VERSION', is_file(base_path('version.md'))
? trim((string) file_get_contents(base_path('version.md')))
: '0.0.1'),
/*
* Description rendered on the home page of the API documentation (`/docs/api`).
*/
'description' => 'REST API for InvoiceShelf — open-source invoicing & expense tracking. '
.'Authenticate with a Sanctum personal access token (Authorization: Bearer <token>). '
.'Company-scoped endpoints additionally require a `company` header carrying the company ID.',
],
'ui' => [
'title' => 'InvoiceShelf API',
],
'renderer' => 'elements',
'renderers' => [
/*
* Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options
*/
'elements' => [
'view' => 'scramble::docs',
'theme' => 'light',
'hideTryIt' => false,
'hideSchemas' => false,
'logo' => '',
'tryItCredentialsPolicy' => 'include',
'layout' => 'responsive',
'router' => 'hash',
],
/*
* Scalar API reference config options: https://scalar.com/products/api-references/configuration
*/
'scalar' => [
'view' => 'scramble::scalar',
'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference',
'theme' => 'laravel',
'proxyUrl' => 'https://proxy.scalar.com',
'darkMode' => false,
'showDeveloperTools' => 'never',
'agent' => ['disabled' => true],
'credentials' => 'include',
],
],
/*
* The list of servers of the API. By default, when `null`, server URL will be created from
* `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you
* will need to specify the local server URL manually (if needed).
*
* Example of non-default config (final URLs are generated using Laravel `url` helper):
*
* ```php
* 'servers' => [
* 'Live' => 'api',
* 'Prod' => 'https://scramble.dedoc.co/api',
* ],
* ```
*/
'servers' => [
// InvoiceShelf is self-hosted: every instance has its own host. A clean
// placeholder is documented here instead of baking in the build-time
// APP_URL (which would leak a dev/CI hostname into the public spec).
// Readers replace it with their own instance URL.
'Your InvoiceShelf instance' => 'https://your-instance.example.com/api/v1',
],
/**
* Determines how Scramble stores the descriptions of enum cases.
* Available options:
* - 'description' Case descriptions are stored as the enum schema's description using table formatting.
* - 'extension' Case descriptions are stored in the `x-enumDescriptions` enum schema extension.
*
* @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions
* - false - Case descriptions are ignored.
*/
'enum_cases_description_strategy' => 'description',
/**
* Determines how Scramble stores the names of enum cases.
* Available options:
* - 'names' Case names are stored in the `x-enumNames` enum schema extension.
* - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension.
* - false - Case names are not stored.
*/
'enum_cases_names_strategy' => false,
/**
* When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated
* OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until
* OpenAPI 3.2 is released and query string structure can be described properly.
*
* For example, this nested validation rule describes the object with `bar` property:
* `['foo.bar' => ['required', 'int']]`.
*
* When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so:
* `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`.
*
* When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so:
* `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`.
*/
'flatten_deep_query_parameters' => true,
'middleware' => [
'web',
RestrictedDocsAccess::class,
],
'extensions' => [],
/*
* Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route
* middleware.
*
* Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`.
* When at least one documented route uses middleware matching the configured patterns (by default
* `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are
* marked as public (`security: []`).
*
* Set to `null` explicitly to disable. If you already configure security manually via
* `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes.
*
* Customize with a class-string or [class, options]:
*
* 'security_strategy' => [
* \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class,
* [
* 'middleware' => ['auth', 'auth:*'],
* 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'),
* ],
* ],
*/
// 'security_strategy' => \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class,
'security_strategy' => null,
];

31244
public/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
<?php
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
// Generating the full ~180-route document via static inference is memory-heavy;
// the test runner's default 128M limit is not enough (the CLI export runs with a
// higher CLI limit). Raise it for this file only.
ini_set('memory_limit', '1024M');
/**
* Build the OpenAPI document in-process (the same call `scramble:export` makes).
* Memoized so the ~180-route inference only runs once across this file's tests —
* the document is deterministic (derived from routes/models, not seeded data).
*/
function openApiDocument(): array
{
static $document;
return $document ??= app(Generator::class)(Scramble::getGeneratorConfig('default'));
}
/** @return list<string> "method path" for every operation carrying a `company` header */
function companyHeaderOperations(array $document): array
{
$matches = [];
foreach ($document['paths'] as $path => $operations) {
foreach ($operations as $method => $operation) {
if (! is_array($operation)) {
continue;
}
foreach ($operation['parameters'] ?? [] as $parameter) {
if (($parameter['name'] ?? null) === 'company' && ($parameter['in'] ?? null) === 'header') {
$matches[] = "{$method} {$path}";
}
}
}
}
return $matches;
}
it('generates a valid OpenAPI document for the v1 API', function () {
$document = openApiDocument();
expect($document['openapi'] ?? '')->toStartWith('3.')
->and($document['info']['title'])->toBe('InvoiceShelf API')
->and($document['info']['version'])->toBe(trim((string) file_get_contents(base_path('version.md'))))
->and($document['paths'])->not->toBeEmpty();
});
it('advertises bearer token authentication', function () {
$document = openApiDocument();
$scheme = collect($document['components']['securitySchemes'] ?? [])
->firstWhere('scheme', 'bearer');
expect($scheme)->not->toBeNull()
->and($scheme['type'])->toBe('http');
});
it('adds the company header to company-scoped routes', function () {
expect(companyHeaderOperations(openApiDocument()))->not->toBeEmpty();
});
it('does not add the company header to unauthenticated bootstrap routes', function () {
$document = openApiDocument();
// /auth/login runs before any tenant is resolved, so it must not require the header.
$loginOperations = $document['paths']['/auth/login'] ?? [];
$hasCompanyHeader = false;
foreach ($loginOperations as $operation) {
if (! is_array($operation)) {
continue;
}
foreach ($operation['parameters'] ?? [] as $parameter) {
if (($parameter['name'] ?? null) === 'company') {
$hasCompanyHeader = true;
}
}
}
expect($hasCompanyHeader)->toBeFalse();
});