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: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"pestphp/pest": "^1.22",
"symfony/var-dumper": "^6.1||^7.0",
"spatie/invade": "^1.1",
"laravel/pint": "^1.4"
"laravel/pint": "^1.4",
"php-mock/php-mock": "^2.5"
},
"license": "MIT",
"autoload": {
Expand Down
2 changes: 0 additions & 2 deletions src/Contents/Retriever.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

interface Retriever
{
public function basePath(): string;

public function from(string $basePath): Retriever;

public function retrieve(string $filePath): Contents;
Expand Down
48 changes: 48 additions & 0 deletions src/Contents/Retrievers/FilesystemRetriever.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Apiboard\OpenAPI\Contents\Retrievers;

use Apiboard\OpenAPI\Contents\Contents;
use Apiboard\OpenAPI\Contents\Retriever;

final class FilesystemRetriever implements Retriever
{
private LocalFilesystemRetriever $local;

private RemoteFilesystemRetriever $remote;

public function __construct()
{
$this->local = new LocalFilesystemRetriever();
$this->remote = new RemoteFilesystemRetriever();
}

public function from(string $baseUrl): Retriever
{
$this->try(fn () => $this->local->from($baseUrl));
$this->try(fn () => $this->remote->from($baseUrl));

return $this;
}

public function retrieve(string $filePath): Contents
{
return $this->try(
fn () => $this->local->retrieve($filePath),
fn () => $this->remote->retrieve($filePath),
);
}

private function try(\Closure $callback, ?\Closure $fallback = null): mixed
{
try {
return $callback();
} catch (\Exception) {
if ($fallback) {
$fallback();
}
}

return null;
}
}
16 changes: 2 additions & 14 deletions src/Contents/Retrievers/LocalFilesystemRetriever.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,12 @@

use Apiboard\OpenAPI\Contents\Contents;
use Apiboard\OpenAPI\Contents\Retriever;
use InvalidArgumentException;
use Symfony\Component\Filesystem\Path;

final class LocalFilesystemRetriever implements Retriever
{
private string $basePath = '';

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

public function from(string $basePath): Retriever
{
$this->basePath = dirname($basePath) . '/';
Expand All @@ -25,16 +19,10 @@ public function from(string $basePath): Retriever

public function retrieve(string $filePath): Contents
{
$extension = pathinfo($filePath, PATHINFO_EXTENSION);

if (str_starts_with($filePath, '/') === false) {
if (Path::isRelative($filePath)) {
$filePath = Path::canonicalize($this->basePath . $filePath);
}

if (in_array($extension, ['json', 'yaml'])) {
return new Contents(file_get_contents($filePath));
}

throw new InvalidArgumentException('Can only parse JSON or YAML files');
return new Contents(file_get_contents($filePath));
}
}
36 changes: 36 additions & 0 deletions src/Contents/Retrievers/RemoteFilesystemRetriever.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Apiboard\OpenAPI\Contents\Retrievers;

use Apiboard\OpenAPI\Contents\Contents;
use Apiboard\OpenAPI\Contents\Retriever;
use Symfony\Component\Filesystem\Path;

final class RemoteFilesystemRetriever implements Retriever
{
private ?string $baseUrl = null;

public function from(string $baseUrl): Retriever
{
$parts = parse_url($baseUrl);
$path = explode('/', $parts['path']);
array_pop($path);

$this->baseUrl = $parts['scheme'] . '://' . $parts['host'] . rtrim(implode('/', $path), '/') . '/';

return $this;
}

public function retrieve(string $url): Contents
{
$validUrl = filter_var($url, FILTER_VALIDATE_URL, FILTER_NULL_ON_FAILURE);

if ($validUrl === null && $this->baseUrl) {
$baseParts = parse_url($this->baseUrl);
$path = Path::canonicalize($baseParts['path'] . $url);
$url = $baseParts['scheme'] . '://' . $baseParts['host'] . $path;
}

return new Contents(file_get_contents($url));
}
}
10 changes: 8 additions & 2 deletions src/OpenAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use Apiboard\OpenAPI\Contents\Contents;
use Apiboard\OpenAPI\Contents\Json;
use Apiboard\OpenAPI\Contents\Retriever;
use Apiboard\OpenAPI\Contents\Retrievers\LocalFilesystemRetriever;
use Apiboard\OpenAPI\Contents\Retrievers\FilesystemRetriever;
use Apiboard\OpenAPI\Contents\Yaml;
use Apiboard\OpenAPI\References\Resolver;
use Apiboard\OpenAPI\Structure\Document;
Expand All @@ -21,7 +21,7 @@ final class OpenAPI

public function __construct(Retriever $retriever = null)
{
$retriever = $retriever ?? new LocalFilesystemRetriever();
$retriever = $retriever ?? new FilesystemRetriever();

$this->retriever = $retriever;
$this->resolver = new Resolver($retriever);
Expand Down Expand Up @@ -76,6 +76,12 @@ public function validate(Json|Yaml $contents): array

private function retrieve(string $filePath): Json|Yaml|Contents
{
$extension = pathinfo($filePath, PATHINFO_EXTENSION);

if (in_array($extension, ['json', 'yaml']) === false) {
throw new InvalidArgumentException('Can only parse JSON or YAML files');
}

$contents = $this->retriever->from($filePath)->retrieve($filePath);

return match (true) {
Expand Down
73 changes: 73 additions & 0 deletions tests/Contents/Retrievers/RemoteFilesystemRetrieverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

use Apiboard\OpenAPI\Contents\Retrievers\RemoteFilesystemRetriever;
use phpmock\Mock;
use phpmock\MockBuilder;

$fileContentsMock = new class () {
protected ?Mock $mock = null;

protected array $called = [];

protected array $contents = [];

public function addContents(string $path, string $contents): self
{
$this->contents[$path] = $contents;

return $this;
}

public function assertCalledWith(string $path): void
{
expect($this->called)->toContain($path);
}

public function __invoke($path)
{
$this->called[] = $path;

return $this->contents[$path];
}

public function mock(): Mock
{
return $this->mock ??= (new MockBuilder())
->setNamespace('\Apiboard\OpenAPI\Contents\Retrievers')
->setName('file_get_contents')
->setFunction($this)
->build();
}
};

beforeEach(fn () => $fileContentsMock->mock()->enable());

afterEach(fn () => $fileContentsMock->mock()->disable());

function remoteRetriever(): RemoteFilesystemRetriever
{
return new RemoteFilesystemRetriever();
}

test('it can retrieve files with urls', function () use ($fileContentsMock) {
$url = 'https://example.com/api/spec.json';
$fileContentsMock->addContents($url, 'the contents!');

$result = remoteRetriever()->retrieve($url);

$fileContentsMock->assertCalledWith($url);
expect($result->toString())->toEqual('the contents!');
});

test('it can retrieve files with relative path using the configured base path', function () use ($fileContentsMock) {
$baseUrl = 'https://example.com/api/spec.json';
$url = './other-spec.json';
$fileContentsMock->addContents('https://example.com/api/other-spec.json', 'the contents!');

$result = remoteRetriever()->from($baseUrl)->retrieve($url);

$fileContentsMock->assertCalledWith('https://example.com/api/other-spec.json');
expect($result->toString())->toEqual('the contents!');
});