Skip to content

Commit 42968d0

Browse files
committed
feat: support usage as Composer dependency (bin script, path resolution, Accept header)
- Add bin/openapi-mock-server with autoloader detection for standalone and dependency usage - Resolve relative OPENAPI_SPEC paths from getcwd() when installed as dependency, from package root when standalone - Replace Content-Type based response generation with Accept header content negotiation, enabling correct Content-Type for application/ld+json and other non-default media types
1 parent 5cab09f commit 42968d0

12 files changed

Lines changed: 264 additions & 74 deletions

bin/openapi-mock-server

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
use Mezzio\Application;
7+
use Mezzio\MiddlewareFactory;
8+
use Psr\Container\ContainerInterface;
9+
10+
(static function (): void {
11+
// Find the Composer autoloader (works both standalone and as dependency)
12+
$autoloadPaths = [
13+
__DIR__ . '/../../../autoload.php', // installed as dependency: vendor/webproject-xyz/php-openapi-mock-server/bin/
14+
__DIR__ . '/../vendor/autoload.php', // standalone: project-root/bin/
15+
];
16+
17+
$autoloaderFound = false;
18+
foreach ($autoloadPaths as $autoloadPath) {
19+
if (file_exists($autoloadPath)) {
20+
require $autoloadPath;
21+
$autoloaderFound = true;
22+
break;
23+
}
24+
}
25+
26+
if (! $autoloaderFound) {
27+
fwrite(STDERR, 'Could not find Composer autoloader. Please run "composer install".' . PHP_EOL);
28+
exit(1);
29+
}
30+
31+
// Resolve relative OPENAPI_SPEC paths to absolute from the current working directory
32+
$specPath = getenv('OPENAPI_SPEC');
33+
if ($specPath !== false && $specPath !== '' && ! str_starts_with($specPath, '/') && ! str_starts_with($specPath, 'http')) {
34+
putenv('OPENAPI_SPEC=' . getcwd() . '/' . $specPath);
35+
}
36+
37+
// Optional Codeception coverage collector
38+
$c3Path = dirname(__DIR__) . '/c3.php';
39+
if (file_exists($c3Path)) {
40+
require $c3Path;
41+
}
42+
43+
/** @var ContainerInterface $container */
44+
$container = require dirname(__DIR__) . '/config/container.php';
45+
46+
/** @var Application $app */
47+
$app = $container->get(Application::class);
48+
49+
/** @var MiddlewareFactory $factory */
50+
$factory = $container->get(MiddlewareFactory::class);
51+
52+
(require dirname(__DIR__) . '/config/pipeline.php')($app, $factory);
53+
(require dirname(__DIR__) . '/config/routes.php')($app, $factory);
54+
55+
$app->run();
56+
})();

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
"slam/phpstan-laminas-framework": "^2.0",
5252
"webproject-xyz/codeception-module-ai-reporter": "^1.0.1"
5353
},
54+
"bin": [
55+
"bin/openapi-mock-server"
56+
],
5457
"autoload": {
5558
"psr-4": {
5659
"WebProject\\PhpOpenApiMockServer\\": "src/"

composer.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/routes.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@
1111
use WebProject\PhpOpenApiMockServer\Middleware\MockMiddleware\Utils\RemoteSpecificationLoader;
1212

1313
return static function (Application $application, MiddlewareFactory $middlewareFactory): void {
14-
$specPath = getenv('OPENAPI_SPEC') ?: 'data/openapi.yaml';
15-
$projectRoot = realpath(__DIR__ . '/..') ?: '/app';
14+
$specPath = getenv('OPENAPI_SPEC') ?: null;
15+
$packageRoot = realpath(__DIR__ . '/..') ?: '/app';
1616

17-
$specHandler = static function (ServerRequestInterface $serverRequest) use ($specPath, $projectRoot): ResponseInterface {
17+
if ($specPath === null || $specPath === false) {
18+
$specPath = $packageRoot . '/data/openapi.yaml';
19+
} elseif (!str_starts_with((string) $specPath, '/') && !str_starts_with((string) $specPath, 'http')) {
20+
$resolveBase = str_contains($packageRoot, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)
21+
? (getcwd() ?: '.')
22+
: $packageRoot;
23+
$specPath = $resolveBase . DIRECTORY_SEPARATOR . $specPath;
24+
}
25+
26+
$specHandler = static function (ServerRequestInterface $serverRequest) use ($specPath): ResponseInterface {
1827
try {
1928
$path = $specPath;
20-
if (!str_starts_with((string) $path, '/') && !str_starts_with((string) $path, 'http')) {
21-
$path = $projectRoot . DIRECTORY_SEPARATOR . $path;
22-
}
2329

2430
if (str_starts_with((string) $path, 'http')) {
2531
$content = file_get_contents($path, false, RemoteSpecificationLoader::createStreamContext());

src/Factory/OpenApiMockMiddlewareFactory.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020

2121
use function dirname;
2222
use function file_get_contents;
23+
use function getcwd;
2324
use function getenv;
2425
use function sprintf;
26+
use function str_contains;
2527
use function str_ends_with;
2628
use function str_starts_with;
2729

@@ -31,15 +33,21 @@ class OpenApiMockMiddlewareFactory
3133
{
3234
public function __invoke(ContainerInterface $container): MiddlewareInterface
3335
{
34-
$projectRoot = dirname(__DIR__, 2);
36+
$packageRoot = dirname(__DIR__, 2);
3537
/** @var array{openapi_mock?: array{spec?: string, validate_request?: bool, validate_response?: bool}} $config */
3638
$config = $container->has('config') ? (array) $container->get('config') : [];
3739
$mockConfig = (array) ($config['openapi_mock'] ?? []);
38-
$specPath = $mockConfig['spec'] ?? getenv('OPENAPI_SPEC') ?: $projectRoot . '/data/openapi.yaml';
40+
$specPath = $mockConfig['spec'] ?? getenv('OPENAPI_SPEC') ?: null;
3941

40-
// Ensure absolute path if relative and not a URL
41-
if (! str_starts_with((string) $specPath, '/') && ! str_starts_with((string) $specPath, 'http')) {
42-
$specPath = $projectRoot . DIRECTORY_SEPARATOR . $specPath;
42+
if ($specPath === null) {
43+
// No spec configured — use default from the package itself
44+
$specPath = $packageRoot . '/data/openapi.yaml';
45+
} elseif (! str_starts_with((string) $specPath, '/') && ! str_starts_with((string) $specPath, 'http')) {
46+
// Relative paths: resolve from cwd when installed as dependency, package root otherwise
47+
$resolveBase = str_contains($packageRoot, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)
48+
? (getcwd() ?: '.')
49+
: $packageRoot;
50+
$specPath = $resolveBase . DIRECTORY_SEPARATOR . $specPath;
4351
}
4452

4553
$openApiMockMiddlewareConfig = new OpenApiMockMiddlewareConfig(

src/Middleware/MockMiddleware/Faker/OpenAPIFaker.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Webmozart\Assert\Assert;
2424

2525
use function array_key_exists;
26+
use function array_keys;
2627

2728
final class OpenAPIFaker
2829
{
@@ -164,6 +165,26 @@ public function setOptions(array $options): self
164165
return $this;
165166
}
166167

168+
/**
169+
* @return list<string>
170+
*/
171+
public function getAvailableResponseContentTypes(
172+
string $path,
173+
string $method,
174+
string $statusCode = '200',
175+
): array {
176+
$operation = $this->findOperation($path, HttpMethod::fromString($method));
177+
178+
if ($operation->responses === null || ! $operation->responses->hasResponse($statusCode)) {
179+
return [];
180+
}
181+
182+
/** @var Response $response */
183+
$response = $operation->responses->getResponse($statusCode);
184+
185+
return array_keys($response->content);
186+
}
187+
167188
private function findOperation(string $path, HttpMethod $httpMethod): Operation
168189
{
169190
try {

src/Middleware/MockMiddleware/OpenApiMockMiddleware.php

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@
88
use WebProject\PhpOpenApiMockServer\Middleware\MockMiddleware\Response\ResponseHandler;
99
use WebProject\PhpOpenApiMockServer\Middleware\MockMiddleware\Validator\RequestValidator;
1010
use WebProject\PhpOpenApiMockServer\Middleware\MockMiddleware\Validator\ResponseValidator;
11-
use Exception;
11+
use function array_map;
12+
use function array_slice;
13+
use function explode;
1214
use const FILTER_VALIDATE_BOOLEAN;
1315
use function filter_var;
1416
use Psr\Http\Message\ResponseInterface;
1517
use Psr\Http\Message\ServerRequestInterface;
1618
use Psr\Http\Server\MiddlewareInterface;
1719
use Psr\Http\Server\RequestHandlerInterface;
20+
use function str_contains;
21+
use function str_starts_with;
22+
use function substr;
1823
use Throwable;
24+
use function trim;
25+
use function usort;
1926

2027
class OpenApiMockMiddleware implements MiddlewareInterface
2128
{
@@ -25,7 +32,7 @@ class OpenApiMockMiddleware implements MiddlewareInterface
2532

2633
public const string HEADER_OPENAPI_MOCK_EXAMPLE = 'X-OpenApi-Mock-Example';
2734

28-
public const string HEADER_CONTENT_TYPE = 'Content-Type';
35+
public const string HEADER_ACCEPT = 'Accept';
2936

3037
public const string DEFAULT_CONTENT_TYPE = 'application/json';
3138

@@ -40,10 +47,10 @@ public function __construct(
4047

4148
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
4249
{
43-
$isActive = $this->isActive($request);
44-
$statusCode = $this->getStatusCode($request);
45-
$contentType = $this->getContentType($request);
46-
$exampleName = $this->getExample($request);
50+
$isActive = $this->isActive($request);
51+
$statusCode = $this->getStatusCode($request);
52+
$acceptedContentTypes = $this->getAcceptedContentTypes($request);
53+
$exampleName = $this->getExample($request);
4754

4855
$path = $request->getUri()->getPath();
4956
if ($path === '/' || $path === '/openapi.yaml' || $path === '/openapi.json') {
@@ -64,7 +71,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
6471
$response = $this->requestHandler->handleValidRequest(
6572
$requestValidatorResult->getSchema(),
6673
$requestValidatorResult->getOperationAddress(),
67-
$contentType,
74+
$acceptedContentTypes,
6875
$statusCode,
6976
$exampleName
7077
);
@@ -76,7 +83,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
7683
);
7784

7885
if ($responseResult->getException() instanceof Throwable) {
79-
return $this->responseHandler->handleInvalidResponse($responseResult->getException(), $contentType);
86+
return $this->responseHandler->handleInvalidResponse(
87+
$responseResult->getException(),
88+
$acceptedContentTypes[0] ?? self::DEFAULT_CONTENT_TYPE
89+
);
8090
}
8191

8292
return $response;
@@ -85,7 +95,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
8595
$exception,
8696
isset($requestValidatorResult) ? $requestValidatorResult->getSchema() : null,
8797
isset($requestValidatorResult) ? $requestValidatorResult->getOperationAddress() : null,
88-
$contentType
98+
$acceptedContentTypes
8999
);
90100
}
91101
}
@@ -104,11 +114,47 @@ private function getStatusCode(ServerRequestInterface $serverRequest): ?string
104114
return empty($statusCode) ? null : $statusCode;
105115
}
106116

107-
private function getContentType(ServerRequestInterface $serverRequest): string
117+
/**
118+
* Parse the Accept header into a list of content types ordered by quality preference.
119+
*
120+
* @return list<string>
121+
*/
122+
private function getAcceptedContentTypes(ServerRequestInterface $serverRequest): array
108123
{
109-
$contentType = $serverRequest->getHeader(self::HEADER_CONTENT_TYPE)[0] ?? self::DEFAULT_CONTENT_TYPE;
124+
$accept = $serverRequest->getHeaderLine(self::HEADER_ACCEPT);
110125

111-
return empty($contentType) ? self::DEFAULT_CONTENT_TYPE : $contentType;
126+
if ($accept === '' || $accept === '*/*') {
127+
return [self::DEFAULT_CONTENT_TYPE];
128+
}
129+
130+
/** @var list<array{type: string, quality: float}> $types */
131+
$types = [];
132+
133+
foreach (explode(',', $accept) as $part) {
134+
$part = trim($part);
135+
$quality = 1.0;
136+
137+
if (str_contains($part, ';')) {
138+
$segments = explode(';', $part);
139+
$part = trim($segments[0]);
140+
foreach (array_slice($segments, 1) as $param) {
141+
$param = trim($param);
142+
if (str_starts_with($param, 'q=')) {
143+
$quality = (float) substr($param, 2);
144+
}
145+
}
146+
}
147+
148+
if ($part !== '') {
149+
$types[] = ['type' => $part, 'quality' => $quality];
150+
}
151+
}
152+
153+
usort($types, static fn (array $a, array $b): int => $b['quality'] <=> $a['quality']);
154+
155+
$result = array_map(static fn (array $t): string => $t['type'], $types);
156+
157+
return $result !== [] ? $result : [self::DEFAULT_CONTENT_TYPE];
112158
}
113159

114160
private function getExample(ServerRequestInterface $serverRequest): ?string

0 commit comments

Comments
 (0)