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 .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 .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/vendor/
/.build/
/.phpunit.cache/

161 changes: 155 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,171 @@
# 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,
Expand Down
28 changes: 28 additions & 0 deletions 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 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
Loading