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
26 changes: 26 additions & 0 deletions packages/api/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- uses: ramsey/composer-install@v3
- run: composer verify

4 changes: 4 additions & 0 deletions packages/api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/vendor/
/.build/
/.phpunit.cache/

204 changes: 198 additions & 6 deletions packages/api/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,215 @@
# pushinbr/pam-api
# PAM API

The optional Express-like HTTP layer for Pam: route parameters, 404/405 handling,
a precompiled middleware pipeline, error boundaries and Composer provider
discovery.
Express-like routing. Laravel-like application structure. PAM-native execution.

**[Official documentation](https://push-in.github.io/pam-docs/packages/api/) ·
[PAM introduction](https://push-in.github.io/pam-docs/introduction/) ·
[Report an issue](https://github.com/push-in/pam-api/issues)**

```bash
pam composer require pushinbr/pam-api
```

```php
use Pam\App;
use Pam\Api\RouteConstraint;

$app = new App();
$app->get('/users/{id}', static fn ($request, $response) =>
$response->json(['id' => $request->route('id')]));

$app->post('/login', [LoginController::class, 'onLogin']);

$app->get('/users/{id}', [UserController::class, 'show'])
->where('id', RouteConstraint::Integer)
->name('users.show');

$app->listen(3000);
```

Controllers are resolved through the container. Both constructor dependencies
and action parameters are injected:

```php
final readonly class LoginController
{
public function __construct(private LoginService $login) {}

public function onLogin(LoginRequest $request): AuthResource
{
return new AuthResource($this->login->handle($request->validated()));
}
}
```

## Route groups

```php
$app->prefix('/api/v1')
->middleware(Authenticate::class)
->group(function (RouteRegistrar $routes): void {
$routes->apiResource('/users', UserController::class);
$routes->post('/login', [LoginController::class, 'onLogin']);
});
```

Global, group and route middleware use the same PAM middleware contract.

## Container lifetimes

```php
$app->container()->bind(UserRepository::class, DatabaseUserRepository::class);
$app->container()->singleton(Cache::class, RedisCache::class);
$app->container()->scoped(CurrentUser::class);
```

`scoped` values are created once per request and discarded even when the
handler throws. This boundary is essential for PAM's persistent workers.

## Validation and resources

```php
final class LoginRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'type' => ['required', Rule::enum(UserType::class)],
];
}
}

enum UserType: int
{
case Regular = 1;
case Administrator = 2;
}
```

Return a `JsonResource` from a handler to receive a consistent `data` envelope.
Validation failures use Problem Details with stable sequential integer codes.

## Quality gate

```bash
composer install
composer verify
```

The verification gate runs PHPStan at level 9 and the PHPUnit suite on every
supported PHP version.

See the [PAM API 2 design and delivery contract](docs/API-2.md) for the complete
15-track implementation plan and current delivery status.

## Distributed rate limiting

`RateLimitMiddleware` uses a bounded in-memory token bucket by default and
accepts any `RateLimitStore` for process-wide or distributed enforcement:

```php
$app->middleware(new RateLimitMiddleware(
requestsPerSecond: 20,
burst: 40,
store: $redisRateLimitStore,
keyResolver: static fn (Request $request): string =>
'token:' . $request->getHeader('authorization', 'anonymous'),
));
```

The middleware emits limit/remaining/retry headers and a Problem Details `429`
response. Applications behind proxies must supply a key resolver that trusts
only their explicitly configured proxy boundary.

## Production building blocks

PAM API exposes small, replaceable contracts instead of choosing application
infrastructure:

- authenticators, principals and ability checks;
- idempotency and response-cache stores;
- request-scoped tenant resolution;
- transactions, events and bounded jobs;
- retry, circuit breakers and cooperative deadlines;
- normalized observations, health checks and scope diagnostics.

Shared production state belongs in atomic Redis/database/broker adapters. The
included memory stores are bounded and intended for development and tests.

## OpenAPI and generated clients

```php
$app->post('/users', [UserController::class, 'store'])
->name('users.store')
->summary('Create a user')
->tags(['Users'])
->input(StoreUserRequest::class)
->output(UserResource::class);

$contract = $app->openApi('My API', '1.0.0');
$openapi = $contract->toJson();
$typescript = $contract->client(ClientLanguage::TypeScript);
$kotlin = $contract->client(ClientLanguage::Kotlin);
$swift = $contract->client(ClientLanguage::Swift);
```

`CompatibilityChecker` reports breaking path and operation removals using
sequential integer codes.

## In-memory testing

```php
(new TestClient($app))
->postJson('/login', ['email' => 'dev@pam.dev'])
->assertStatus(200)
->assertJsonPath('data.status', 1);
```

Run `composer benchmark` for the standalone router benchmark.

## License

Free and open-source under the [Apache License 2.0](LICENSE). You may use,
modify, and distribute this package for any purpose, including commercially.


## Recommended PAM workflow

Start new applications with `pam init my-api --template api`. In an existing PAM project, install the higher-level router with `pam composer require pushinbr/pam-api`; PAM runs Composer inside its private Embed SAPI.

Run `pam doctor` after dependency changes and before creating a release. The project remains a normal Composer project with a standard manifest, lockfile, PSR-4 autoloading, and `vendor/autoload.php`.

## API guide

| Surface | Use it for |
| --- | --- |
| `App` | Register routes, middleware, providers, error boundaries, and the listener. |
| `Router` | Compile and match method/path routes with typed results. |
| `Pipeline` | Execute middleware and the destination handler in order. |
| `CorsMiddleware` | Apply explicit origin, method, and header policy. |
| `RateLimitMiddleware` | Apply bounded per-key request limits. |
| `SecurityHeadersMiddleware` | Set conservative browser security headers. |

Route parameters are available through `$request->route()`. A path that exists for another method produces 405 behavior; an unknown path produces 404 behavior. Register error handling with `onError()` and keep transport-level timeouts and request limits in the PAM listener options.

## Production checklist

- Keep request data and mutable state scoped to the current request.
- Test success, validation failure, exception, cancellation, and timeout paths.
- Configure explicit limits and avoid unbounded payloads, queues, or retained collections.
- Run `pam doctor`, `pam test`, and the relevant integration suite before release.
- Validate real dependencies and workload behavior; compatibility is not inferred from package installation alone.

## Troubleshooting

- **Class not found:** run `pam composer install`, verify PSR-4 configuration, and rerun `pam doctor`.
- **Behavior differs over the network:** reproduce with PAM's transport integration tests; in-memory execution does not model the socket boundary.
- **A dependency blocks a worker:** use PAM-native I/O, a compatible event loop, a process pool, or additional isolated workers.

## Documentation and support

- [PAM introduction](https://push-in.github.io/pam-docs/introduction/)
- [Package ecosystem](https://push-in.github.io/pam-docs/packages/overview/)
- [Runtime compatibility](https://push-in.github.io/pam-docs/runtime/compatibility/)
- [Report an issue](https://github.com/push-in/pam-api/issues)

Report security vulnerabilities through GitHub private vulnerability reporting or the PAM security policy, not a public issue.
28 changes: 28 additions & 0 deletions packages/api/benchmarks/router.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use Pam\Api\RouteConstraint;
use Pam\Api\Router;

$router = new Router();
for ($index = 1; $index <= 100; ++$index) {
$router->add('GET', "/static/{$index}", static fn (): null => null);
}
$dynamic = $router->register('GET', '/users/{id}', static fn (): null => null);
$router->constrain($dynamic, 'id', RouteConstraint::Integer);

$iterations = 100_000;
$startedAt = hrtime(true);
for ($index = 0; $index < $iterations; ++$index) {
$router->match('GET', $index % 2 === 0 ? '/static/50' : '/users/42');
}
$seconds = (hrtime(true) - $startedAt) / 1_000_000_000;

echo json_encode([
'iterations' => $iterations,
'seconds' => $seconds,
'matchesPerSecond' => (int) round($iterations / $seconds),
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), "\n";
28 changes: 27 additions & 1 deletion packages/api/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
"type": "library",
"license": "Apache-2.0",
"keywords": [
"api",
"dependency-injection",
"http",
"middleware",
"openapi",
"pam",
"php",
"router"
"router",
"validation"
],
"homepage": "https://github.com/push-in/pam",
"support": {
"docs": "https://push-in.github.io/pam-docs/packages/api/",
"issues": "https://github.com/push-in/pam/issues",
"source": "https://github.com/push-in/pam-api"
},
Expand All @@ -31,7 +36,28 @@
"Pam\\Api\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Pam\\Api\\Tests\\": "tests/"
},
"files": [
"tests/bootstrap.php"
]
},
"scripts": {
"analyse": "phpstan analyse --configuration=phpstan.neon --memory-limit=1G",
"benchmark": "php benchmarks/router.php",
"test": "phpunit --configuration=phpunit.xml",
"verify": [
"@analyse",
"@test"
]
},
"config": {
"sort-packages": true
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.5"
}
}
Loading