Skip to content
5 changes: 4 additions & 1 deletion src/Http/Middleware/LogApiRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ public function handle($request, \Closure $next)
*/
protected function shouldLog($request): bool
{
if (!Http::isPublicRequest($request)) {
// Every route that runs this middleware opted in to logging through its
// middleware group (core's public API or an extension's), so only skip
// requests without a route and internal routes such as "int/v1/..."
if ($request->route() === null || Http::isInternalRequest($request)) {
return false;
}

Expand Down
17 changes: 17 additions & 0 deletions src/Jobs/LogApiRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ class LogApiRequest implements ShouldQueue
use Queueable;
use SerializesModels;

/**
* Request attribute holding ids to add to the log's related column.
*
* Extensions set it from their middleware so a logged request can be traced
* to the resource behind it, for example the owner of the key that made it.
*
* @var string
*/
public const RELATED_ATTRIBUTE = 'api_request_log_related';

/**
* The API Request/JsonResponse payload.
*
Expand Down Expand Up @@ -82,6 +92,13 @@ public static function getPayload(Request $request, $response): array
$related[] = Utils::get($content, 'id');
}

// Push ids that middleware related to this request, such as the resource whose key made it
foreach ((array) $request->attributes->get(static::RELATED_ATTRIBUTE, []) as $id) {
if ($id) {
$related[] = $id;
}
}

// Get api credential from session
$apiCredential = session('api_credential');

Expand Down
2 changes: 1 addition & 1 deletion src/Support/ApiModelCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ public static function getRelationshipTtl(): int
protected static function getCompanyUuid(Request $request): ?string
{
// Try to get from session
if ($request->session()->has('company')) {
if ($request->hasSession() && $request->session()->has('company')) {
return $request->session()->get('company');
}

Expand Down
44 changes: 44 additions & 0 deletions tests/Unit/Http/MiddlewareContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -660,15 +660,27 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques
middleware_contracts_mutation_request('/int/v1/orders', 'int/v1/orders'),
fn () => new JsonResponse(['id' => 'order_2'], 201)
);
$storefrontInternal = middleware_contracts_log_middleware()->handle(
middleware_contracts_mutation_request('/storefront/int/v1/orders', 'storefront/int/v1/orders'),
fn () => new JsonResponse(['id' => 'order_3'], 201)
);
$excluded = middleware_contracts_log_middleware()->handle(
middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'),
fn () => new JsonResponse(['id' => 'track_1'], 201)
);
$unroutedRequest = middleware_contracts_mutation_request('/v1/orders', 'v1/orders');
$unroutedRequest->setRouteResolver(fn () => null);
$unrouted = middleware_contracts_log_middleware()->handle(
$unroutedRequest,
fn () => new JsonResponse(['id' => 'order_4'], 201)
);

expect($disabled->getStatusCode())->toBe(201)
->and($read->getStatusCode())->toBe(204)
->and($internal->getStatusCode())->toBe(201)
->and($storefrontInternal->getStatusCode())->toBe(201)
->and($excluded->getStatusCode())->toBe(201)
->and($unrouted->getStatusCode())->toBe(201)
->and($bus->jobs)->toBeEmpty();
});

Expand Down Expand Up @@ -712,6 +724,38 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques
]);
});

test('log api requests dispatches extension mutation request logs with related ids from the request', function () {
middleware_contracts_fixture(['api.version' => 'v1']);
session([
'company' => 'company-1',
'api_credential' => 'store_live_key',
]);
$bus = new MiddlewareContractsBusFake();
app()->instance('bus', $bus);
app()->instance(BusDispatcher::class, $bus);
Facade::clearResolvedInstance('bus');
Facade::clearResolvedInstance(BusDispatcher::class);

$request = middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code');
$request->attributes->set(Fleetbase\Jobs\LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123']);

$response = middleware_contracts_log_middleware()->handle(
$request,
fn () => new JsonResponse(['status' => 'ok'], 200)
);

expect($response->getStatusCode())->toBe(200)
->and($bus->jobs)->toHaveCount(1)
->and($bus->jobs[0])->toBeInstanceOf(Fleetbase\Jobs\LogApiRequest::class)
->and($bus->jobs[0]->payload)->toMatchArray([
'company_uuid' => 'company-1',
'method' => 'POST',
'path' => 'storefront/v1/customers/request-creation-code',
'status_code' => 200,
'related' => ['store_abc123'],
]);
});

test('log api requests swallows dispatch errors and keeps original response', function () {
middleware_contracts_fixture(['api.version' => 'v1']);
$bus = new MiddlewareContractsBusFake();
Expand Down
24 changes: 24 additions & 0 deletions tests/Unit/Jobs/LogApiRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,30 @@ function loggable_request(float $startedAt): Request
->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']);
});

it('adds ids that middleware related to the request to the payload', function () {
bind_test_container(['api.version' => 'v1']);
session([
'api_credential' => 'store_live_key',
'company' => 'company-1',
]);

$related = loggable_request(microtime(true) - 0.01);
$related->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123', null, '']);

$single = loggable_request(microtime(true) - 0.01);
$single->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, 'network_xyz789');

$relatedPayload = LogApiRequest::getPayload($related, new JsonResponse(['id' => 'customer_1'], 201));
$singlePayload = LogApiRequest::getPayload($single, new JsonResponse(['status' => 'ok'], 200));
$plainPayload = LogApiRequest::getPayload(loggable_request(microtime(true) - 0.01), new JsonResponse(['status' => 'ok'], 200));

expect($relatedPayload['related'])->toBe(['customer_1', 'store_abc123'])
->and($relatedPayload['company_uuid'])->toBe('company-1')
->and($relatedPayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id'])
->and($singlePayload['related'])->toBe(['network_xyz789'])
->and($plainPayload['related'])->toBe([]);
});

it('persists API request logs on the selected connection and clears response cache', function () {
$database = log_api_request_database();
$cache = new LogApiRequestResponseCacheFake();
Expand Down
70 changes: 70 additions & 0 deletions tests/Unit/Support/ApiModelCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,24 @@ class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelation
protected $apiCacheDependencies = ['tags', 'payload.customer'];
}

class ApiModelCacheUncallableRelationsOrder extends ApiModelCacheRelationsOrder
{
public static function staticPayload(): BelongsTo
{
return (new static())->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid');
}

public function payloadFor(string $key): BelongsTo
{
return $this->belongsTo(ApiModelCacheDependencyPayload::class, $key, 'uuid');
}

protected function hiddenPayload(): BelongsTo
{
return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid');
}
}

class ApiModelCacheTestLock
{
public function __construct(private ApiModelCacheTestStore $store)
Expand Down Expand Up @@ -987,6 +1005,41 @@ public function gc(int $max_lifetime): int|false
app()->forgetInstance('db.transactions');
});

test('api model cache keeps the immediate bump when the transactions manager cannot be resolved', function () {
$capsule = api_model_cache_dependency_database();
$store = app('cache');
$store->flushRemovesValues = false;
$connection = $capsule->getConnection('mysql');
app()->bind('db.transactions', fn () => throw new RuntimeException('transactions manager unavailable'));

$connection->beginTransaction();
ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1');
$connection->commit();

app()->offsetUnset('db.transactions');

expect($store->get('api_query_version:payloads:company-1'))->toBe(1);
});

test('api model cache only resolves public instance relation methods without required parameters', function () {
api_model_cache_dependency_database();
$order = new ApiModelCacheUncallableRelationsOrder();

expect(ApiModelCache::dependencyTables($order, ['static_payload', 'payload_for', 'hidden_payload']))->toBe([])
->and(ApiModelCache::dependencyTables($order, ['payload']))->toBe(['payloads']);
});

test('api model cache clears the relation path memo once it reaches its limit', function () {
api_model_cache_dependency_database();
$memo = new ReflectionProperty(ApiModelCache::class, 'dependencyTables');
$limit = (new ReflectionClassConstant(ApiModelCache::class, 'DEPENDENCY_MEMO_LIMIT'))->getValue();
$memo->setAccessible(true);
$memo->setValue(null, array_fill_keys(array_map(fn ($i) => "memo-{$i}", range(1, $limit)), []));

expect(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload']))->toBe(['payloads'])
->and($memo->getValue())->toHaveCount(1);
});

test('api model cache resolves request style relation paths without side effects on the model', function () {
api_model_cache_dependency_database();
$order = new ApiModelCacheRelationsOrder();
Expand Down Expand Up @@ -1182,6 +1235,23 @@ public function gc(int $max_lifetime): int|false
->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b']));
});

test('api model cache keys requests without a session by company input and leaves them unscoped', function () {
api_model_cache_fixture();
$model = api_model_cache_model();

$sessionless = ApiModelCache::generateQueryCacheKey($model, Request::create('/v1/orders', 'GET', [
'status' => 'active',
'company_uuid' => 'company-1',
]));
$unscoped = ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', [], [
'status' => 'active',
'company_uuid' => 'company-1',
]));

expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:')
->and($sessionless)->toBe($unscoped);
});

test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () {
api_model_cache_fixture();
$model = api_model_cache_model();
Expand Down
Loading