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
3 changes: 1 addition & 2 deletions src/FakeRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,7 @@ private static function buildFromInfo(FakeDataContext $context, OperationInfo $i
}
}

$serverUrls = $context->schema()->serverUrls();
$baseUrl = $serverUrls[0] ?? '/';
$baseUrl = $info->serverUrls[0] ?? '/';

return new self(
method: $info->method,
Expand Down
23 changes: 20 additions & 3 deletions src/Interceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
namespace OasFake;

use Closure;

use function in_array;

use League\OpenAPIValidation\PSR7\OperationAddress;
use LogicException;
use OasFake\Exception\ReplayMismatchError;
Expand Down Expand Up @@ -142,9 +145,14 @@ public function handle(VcrRequest $vcrRequest): VcrResponse

private function respondTo(ServerRequestInterface $psrRequest): ResponseInterface
{
$path = $this->operationPathResolver->resolve($this->schema, $psrRequest);
$resolvedPath = $this->operationPathResolver->resolveWithServerUrl($this->schema, $psrRequest);
$path = $resolvedPath['path'];
$method = $psrRequest->getMethod();
$operationInfo = $this->operationLookup->findByRequestPathAndMethod($path, $method);
if ($operationInfo !== null && !$this->operationMatchesServer($operationInfo, $resolvedPath['serverUrl'])) {
$operationInfo = null;
}

$operation = $this->resolveOperation($psrRequest, $operationInfo);
$response = $this->operationResponder->respond($psrRequest, $path, $method, $operationInfo);

Expand All @@ -170,8 +178,12 @@ private function respondTo(ServerRequestInterface $psrRequest): ResponseInterfac
public function replay(VcrRequest $request): VcrResponse
{
$psrRequest = $this->converter->requestToPsr7($request);
$path = $this->operationPathResolver->resolve($this->schema, $psrRequest);
$operationInfo = $this->operationLookup->findByRequestPathAndMethod($path, $psrRequest->getMethod());
$resolvedPath = $this->operationPathResolver->resolveWithServerUrl($this->schema, $psrRequest);
$operationInfo = $this->operationLookup->findByRequestPathAndMethod($resolvedPath['path'], $psrRequest->getMethod());
if ($operationInfo !== null && !$this->operationMatchesServer($operationInfo, $resolvedPath['serverUrl'])) {
$operationInfo = null;
}

$operation = $this->resolveOperation($psrRequest, $operationInfo);
$response = $this->converter->vcrResponseToPsr7($this->playback($request));
$response = $this->middlewarePipeline->process($psrRequest, $response);
Expand All @@ -183,6 +195,11 @@ public function replay(VcrRequest $request): VcrResponse
return $this->converter->psr7ToVcrResponse($response);
}

private function operationMatchesServer(OperationInfo $operationInfo, ?string $serverUrl): bool
{
return $serverUrl === null || in_array($serverUrl, $operationInfo->serverUrls, true);
}

private function playback(VcrRequest $request): VcrResponse
{
if ($this->cassette === null) {
Expand Down
2 changes: 2 additions & 0 deletions src/OperationInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ final class OperationInfo
{
/**
* @param list<Parameter> $parameters
* @param list<string> $serverUrls
*/
public function __construct(
public string $pathPattern,
public string $method,
public string $operationId,
public Operation $operation,
public array $parameters,
public array $serverUrls = ['/'],
) {
}
}
2 changes: 2 additions & 0 deletions src/OperationLookup.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,15 @@ private function index(Schema $schema): void

$mergedParams = $this->mergeParameters($pathLevelParams, $this->extractOperationParameters($operation));
$operationId = $operation->operationId ?? '';
$serverUrls = $schema->effectiveServerUrls($pathItem, $operation);

$info = new OperationInfo(
pathPattern: $pathPattern,
method: $method,
operationId: $operationId,
operation: $operation,
parameters: $mergedParams,
serverUrls: $serverUrls,
);

if ($operationId !== '') {
Expand Down
61 changes: 51 additions & 10 deletions src/OperationPathResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,20 @@ final class OperationPathResolver
* Return the path used by OpenAPI operation lookup for a request.
*/
public function resolve(Schema $schema, ServerRequestInterface $request): string
{
return $this->resolveWithServerUrl($schema, $request)['path'];
}

/**
* Return the operation path and the server URL that was used to resolve it.
*
* @return array{path: string, serverUrl: string|null}
*/
public function resolveWithServerUrl(Schema $schema, ServerRequestInterface $request): array
{
$path = $this->normalizePath($request->getUri()->getPath());
/** @var array{specificity: int, path: string, serverUrl: string}|null $match */
$match = null;

foreach ($schema->serverUrls() as $serverUrl) {
$base = parse_url($serverUrl);
Expand All @@ -35,22 +47,32 @@ public function resolve(Schema $schema, ServerRequestInterface $request): string
}

$basePath = $this->normalizePath((string) ($base['path'] ?? '/'));
if ($basePath === '/') {
return $path;
$operationPath = $this->stripBasePath($path, $basePath);
if ($operationPath === null) {
continue;
}

if ($path === $basePath) {
return '/';
$specificity = $basePath === '/' ? 0 : strlen($basePath);
if ($match === null || $specificity > $match['specificity']) {
$match = [
'specificity' => $specificity,
'path' => $operationPath,
'serverUrl' => $serverUrl,
];
}
}

if (str_starts_with($path, $basePath . '/')) {
$operationPath = substr($path, strlen($basePath));

return $operationPath === '' ? '/' : $operationPath;
}
if ($match === null) {
return [
'path' => $path,
'serverUrl' => null,
];
}

return $path;
return [
'path' => $match['path'],
'serverUrl' => $match['serverUrl'],
];
}

/**
Expand Down Expand Up @@ -106,6 +128,25 @@ private function effectiveRequestPort(ServerRequestInterface $request): ?int
};
}

private function stripBasePath(string $path, string $basePath): ?string
{
if ($basePath === '/') {
return $path;
}

if ($path === $basePath) {
return '/';
}

if (!str_starts_with($path, $basePath . '/')) {
return null;
}

$operationPath = substr($path, strlen($basePath));

return $operationPath === '' ? '/' : $operationPath;
}

private function normalizePath(string $path): string
{
$normalized = '/' . ltrim($path, '/');
Expand Down
80 changes: 72 additions & 8 deletions src/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

use cebe\openapi\Reader;
use cebe\openapi\spec\OpenApi;
use cebe\openapi\spec\Operation;
use cebe\openapi\spec\PathItem;
use cebe\openapi\spec\Server as CebeServer;

use function is_string;

use OasFake\Exception\SchemaNotFoundException;

/**
Expand Down Expand Up @@ -79,25 +85,83 @@ public function openApi(): OpenApi
}

/**
* Return the server URLs defined in the schema, with variables substituted.
* Return the effective server URLs used by operations, with variables substituted.
*
* @return list<string>
*/
public function serverUrls(): array
{
$urls = [];
if ($this->openApi->servers !== null) {
foreach ($this->openApi->servers as $server) {
$url = $server->url;
if ($server->variables !== null) {
foreach ($server->variables as $name => $variable) {
$url = str_replace('{' . $name . '}', $variable->default, $url);

if ($this->openApi->paths !== null) {
/** @var PathItem $pathItem */
foreach ($this->openApi->paths as $path => $pathItem) {
if (!is_string($path)) {
continue;
}

foreach ($pathItem->getOperations() as $operation) {
foreach ($this->effectiveServerUrls($pathItem, $operation) as $url) {
$urls[$url] = true;
}
}
$urls[] = $url;
}
}

if ($urls === []) {
return $this->effectiveServerUrls();
}

return array_keys($urls);
}

/**
* Return the server URLs that apply to one operation.
*
* Operation-level servers override path-level servers, which override root-level servers.
*
* @return list<string>
*/
public function effectiveServerUrls(?PathItem $pathItem = null, ?Operation $operation = null): array
{
if ($operation !== null && $operation->servers !== null && $operation->servers !== []) {
return $this->resolveServerUrls($operation->servers);
}

if ($pathItem !== null && $pathItem->servers !== null && $pathItem->servers !== []) {
return $this->resolveServerUrls($pathItem->servers);
}

if ($this->openApi->servers !== null && $this->openApi->servers !== []) {
return $this->resolveServerUrls($this->openApi->servers);
}

return ['/'];
}

/**
* @param array<int|string, CebeServer> $servers
*
* @return list<string>
*/
private function resolveServerUrls(array $servers): array
{
$urls = [];

foreach ($servers as $server) {
if (!$server instanceof CebeServer) {
continue;
}

$url = $server->url;
if ($server->variables !== null) {
foreach ($server->variables as $name => $variable) {
$url = str_replace('{' . $name . '}', $variable->default, $url);
}
}
$urls[] = $url;
}

return $urls === [] ? ['/'] : $urls;
}
}
46 changes: 46 additions & 0 deletions tests/Fixtures/openapi/mixed-server-petstore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
openapi: 3.0.0
info:
title: Mixed Server Petstore API
version: 1.0.0
servers:
- url: https://root.petstore.example.com
paths:
/pets:
servers:
- url: https://api.path-petstore.example.com/v1
get:
operationId: listPets
responses:
'200':
description: A list of pets
content:
application/json:
schema:
type: array
items:
type: object
required:
- id
- name
properties:
id:
type: integer
name:
type: string
/orders:
get:
operationId: listOrders
responses:
'200':
description: A list of orders
content:
application/json:
schema:
type: array
items:
type: object
required:
- id
properties:
id:
type: integer
29 changes: 29 additions & 0 deletions tests/Fixtures/openapi/path-server-petstore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
openapi: 3.0.0
info:
title: Path Server Petstore API
version: 1.0.0
servers:
- url: https://root.petstore.example.com
paths:
/pets:
servers:
- url: https://api.path-petstore.example.com/v1
get:
operationId: listPets
responses:
'200':
description: A list of pets
content:
application/json:
schema:
type: array
items:
type: object
required:
- id
- name
properties:
id:
type: integer
name:
type: string
Loading