Skip to content
Open
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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ This is a minimal, agnostic PHP SDK for Openapi services with only essential HTT
- `CacheInterface`: Contract for cache implementations
- `ArrayCache`: In-memory cache implementation

- **`TokenManager`** (`src/TokenManager.php`): Laravel-only, opt-in OAuth token provider. Caches tokens in a Laravel cache store (`Illuminate\Contracts\Cache\Repository`), uses atomic locks to prevent stampedes between workers (Octane), serves a still-valid token while one worker refreshes (stale-while-revalidate), and takes the real expiry from the `expire` field of the `/token` response.
- **Laravel integration** (`src/Laravel/`, `config/openapi.php`): service provider (auto-discovered via `composer.json`) binding `OauthClient` and `TokenManager`; cache store configurable with `OPENAPI_CACHE_STORE` (default: the application's default store). `Client` and `OauthClient` remain framework-agnostic.

### Key Design Principles

1. **Agnostic**: No API-specific classes - works with any Openapi service
Expand Down
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,97 @@ $response = $client->delete($url);
$response = $client->patch($url, $payload);
```

## Shared token caching (Laravel Octane / multi-worker)

By default every call to `OauthClient::createToken()` sends a new request to the OAuth server. When many
processes do this at once (for example Laravel Octane/Swoole workers, each with its own memory), the OAuth
server can answer with HTTP 429.

`TokenManager` solves this by keeping the token in Laravel's cache, shared by all processes:

- A fresh token is read from the cache. No lock and no OAuth request.
- If no token exists, one worker takes an atomic lock (`Cache::lock()`) and requests it. The others wait, then read it from the cache.
- If a token is close to expiry, one worker refreshes it while the others keep using the still-valid token (stale-while-revalidate).
- If a refresh fails (for example with a 429) and the old token is still valid, the old token keeps being used.

> **Opt-in, Laravel only.** `TokenManager` needs Laravel's cache (`illuminate/cache`) and is not used
> automatically. `OauthClient` and `Client` work exactly as before and do not depend on Laravel, so plain PHP
> and PHP-FPM users are unaffected. Existing code that calls `createToken()` directly keeps the 429 problem until
> it switches to `TokenManager`.

### Setup

The package service provider (`Openapi\Laravel\OpenapiServiceProvider`) is auto-discovered. Optionally publish the config:

```bash
php artisan vendor:publish --tag=openapi-config
```

Set your credentials and a shared cache store in `.env`:

```dotenv
OPENAPI_OAUTH_USERNAME=...
OPENAPI_OAUTH_APIKEY=...
OPENAPI_OAUTH_TEST=false # set to true to use the test OAuth server (required for test.* scopes)
OPENAPI_CACHE_STORE=redis # recommended under Octane; empty = the application's default cache store
```

### Usage

```php
use Openapi\Client;
use Openapi\TokenManager;

$scopes = ['GET:test.imprese.openapi.it/advance']; // test scope: needs OPENAPI_OAUTH_TEST=true

$token = app(TokenManager::class)->getToken($scopes);
$client = new Client($token);
```

Outside the service provider you can build it yourself with any Laravel cache repository:
`new TokenManager($oauthClient, Cache::store('redis'))`.

#### Cache store requirements

Use a store that is shared between workers **and** supports atomic locks: `redis`, `memcached` or `database`.

- `array` is per-process, so each worker would have its own copy and the problem is not fixed.
- If `OPENAPI_CACHE_STORE` is empty, the application's default store is used. Make sure that default is a shared
store. With `file` (single server only) or `array`, workers do not share tokens reliably, so set
`OPENAPI_CACHE_STORE=redis` (or `memcached`) explicitly.
- A store without lock support is rejected with an `InvalidArgumentException` when `TokenManager` is created.

#### Octane: build the client per request

A `Client` holds the token it was created with. Do not keep one in an Octane singleton, or it will keep using that
token after it expires. Call `getToken()` on each request and create the `Client` from it.

#### Token lifetime

`getToken($scopes, $ttl = 3600)` asks the OAuth server for a token valid for `$ttl` seconds, but only when a new
token is needed. The expiry the server reports in the `expire` field (a unix timestamp) is what the cache uses, so a
token the server shortens is never served past its real expiry, and a token is never trusted for longer than the
`$ttl` you asked for. If the server sends no usable `expire`, `$ttl` is used. Keep the clocks of your servers
synchronised (NTP), since `expire` is compared with the local clock.

A cached token is reused whatever `$ttl` is passed: tokens are cached per OAuth URL, username and set of scopes
(scope order does not matter, and the API key is never part of the key).

The refresh margin (`refresh_skew`) is capped at half of the token's lifetime, so short-lived tokens are cached
and refreshed too, instead of being requested on every call.

#### Configuration

Options live under the `token` key of `config/openapi.php`:

| Option | Default | Description |
|---|---|---|
| `store` | `null` (`OPENAPI_CACHE_STORE`) | Cache store name. `null` uses the application's default store. |
| `prefix` | `openapi:token:` (`OPENAPI_CACHE_PREFIX`) | Prefix for cache keys. |
| `refresh_skew` | `300` | Seconds before expiry at which a token starts being refreshed (capped at half of the token's lifetime). |
| `lock_seconds` | `45` | Maximum lifetime of the lock (a safety net if a worker dies while holding it). Keep it above the OAuth request timeout (30 seconds). |
| `lock_wait` | `15` | Seconds a worker waits for another worker's token request when no token exists yet. If it times out, an `ApiException` is thrown. |

## Architecture

This SDK follows a minimal approach with only essential components:
Expand All @@ -101,6 +192,7 @@ This SDK follows a minimal approach with only essential components:
- `Client`: Agnostic HTTP client for API calls
- `Exception`: Error handling
- `Cache\CacheInterface`: Optional caching interface
- `TokenManager`: Laravel cache-backed, lock-protected token provider (opt-in)

## Requirements

Expand Down
13 changes: 12 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@
"symfony/dotenv": "^5.3",
"phpunit/phpunit": "^9.5",
"phpstan/phpstan": "^2.1",
"squizlabs/php_codesniffer": "^4.0"
"squizlabs/php_codesniffer": "^4.0",
"illuminate/cache": "^9.0 || ^10.0 || ^11.0 || ^12.0"
},
"autoload": {
"psr-4": {
Expand All @@ -108,5 +109,15 @@
"example:complete": "php examples/complete_workflow.php",
"cs": "phpcs",
"cs-fix": "phpcbf"
},
"suggest": {
"illuminate/cache": "Required to use TokenManager, which shares OAuth tokens across Laravel Octane workers through a cache store (use Redis or Memcached)"
},
"extra": {
"laravel": {
"providers": [
"Openapi\\Laravel\\OpenapiServiceProvider"
]
}
}
}
31 changes: 31 additions & 0 deletions config/openapi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

return [
'username' => env('OPENAPI_OAUTH_USERNAME'),
'apikey' => env('OPENAPI_OAUTH_APIKEY'),
'test' => env('OPENAPI_OAUTH_TEST', false),

'token' => [
/*
| Cache store used to share OAuth tokens between processes.
| null = the application's default store (CACHE_STORE / CACHE_DRIVER).
| Under Laravel Octane use a store shared across workers that supports atomic
| locks: redis, memcached or database. "array" is per-process.
*/
'store' => env('OPENAPI_CACHE_STORE'),

'prefix' => env('OPENAPI_CACHE_PREFIX', 'openapi:token:'),

// Seconds before expiry at which a token starts being refreshed. While one worker
// refreshes it, the others keep using the still-valid token. Capped at half of the
// token's lifetime so short-lived tokens are still cached.
'refresh_skew' => 300,

// Maximum lifetime of the refresh lock (safety net if a worker dies mid-request).
// Keep it above the OAuth request timeout (30 seconds).
'lock_seconds' => 45,

// How long a worker waits for another worker's token request when no token exists.
'lock_wait' => 15,
],
];
47 changes: 47 additions & 0 deletions src/Laravel/OpenapiServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Openapi\Laravel;

use Illuminate\Support\ServiceProvider;
use Openapi\OauthClient;
use Openapi\TokenManager;

class OpenapiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/openapi.php', 'openapi');

$this->app->singleton(OauthClient::class, function ($app) {
$config = $app['config']->get('openapi');

return new OauthClient(
$config['username'] ?? null,
$config['apikey'] ?? null,
(bool) ($config['test'] ?? false)
);
});

$this->app->singleton(TokenManager::class, function ($app) {
$config = $app['config']->get('openapi.token', []);

return new TokenManager(
$app->make(OauthClient::class),
$app['cache']->store($config['store'] ?? null),
$config['prefix'] ?? 'openapi:token:',
(int) ($config['refresh_skew'] ?? 300),
(int) ($config['lock_seconds'] ?? 45),
(int) ($config['lock_wait'] ?? 15)
);
});
}

public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../../config/openapi.php' => $this->app->configPath('openapi.php'),
], 'openapi-config');
}
}
}
10 changes: 10 additions & 0 deletions src/OauthClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ public function __construct(?string $username = null, ?string $apikey = null, bo
: (getenv('OPENAPI_OAUTH_URL') ?: self::OAUTH_BASE_URL);
}

public function getUsername(): string
{
return $this->username;
}

public function getBaseUrl(): string
{
return $this->url;
}

public function getScopes(bool $limit = false): string
{
$params = ['limit' => $limit ? 1 : 0];
Expand Down
Loading