diff --git a/.gitignore b/.gitignore
index 47d1cb8..417d292 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
-vendor/
-.phpunit.result.cache
-composer.lock
+/vendor
+/composer.lock
+/.phpunit.result.cache
+/.php_cs.cache
+/coverage
+/infection.log
diff --git a/.php_cs.dist b/.php_cs.dist
new file mode 100644
index 0000000..14db556
--- /dev/null
+++ b/.php_cs.dist
@@ -0,0 +1,8 @@
+in(__DIR__.'/src')
+ ->in(__DIR__.'/tests');
+
+return TiMacDonald\styles($finder);
+
diff --git a/composer.json b/composer.json
index c68652b..eb1b85b 100644
--- a/composer.json
+++ b/composer.json
@@ -1,13 +1,13 @@
{
"name": "timacdonald/multiformat-response-objects",
"description": "A response object that handles multiple response formats within the one controller",
- "license": "MIT",
"keywords": [
"multiformat",
"responsable",
"response objects",
"laravel"
],
+ "license": "MIT",
"authors": [
{
"name": "Tim MacDonald",
@@ -17,22 +17,64 @@
],
"require": {
"php": "^7.2",
- "illuminate/support": "5.8.*",
"illuminate/http": "5.8.*",
- "symfony/mime": "^4.3"
+ "illuminate/support": "5.8.*",
+ "symfony/mime": "^5.1"
},
"require-dev": {
+ "ergebnis/composer-normalize": "^2.7",
+ "infection/infection": "^0.17.2",
+ "orchestra/testbench": "^3.5",
+ "phpstan/phpstan": "^0.12.38",
"phpunit/phpunit": "^8.0",
- "orchestra/testbench": "^3.5"
+ "timacdonald/php-style": "dev-master",
+ "vimeo/psalm": "^3.15"
+ },
+ "config": {
+ "preferred-install": "dist",
+ "sort-packages": true
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "TiMacDonald\\Multiformat\\MultiformatResponseServiceProvider"
+ ]
+ }
},
"autoload": {
"psr-4": {
- "TiMacDonald\\MultiFormat\\": "src"
+ "TiMacDonald\\Multiformat\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "scripts": {
+ "fix": [
+ "clear",
+ "@composer normalize",
+ "./vendor/bin/php-cs-fixer fix"
+ ],
+ "lint": [
+ "clear",
+ "@composer normalize --dry-run",
+ "./vendor/bin/php-cs-fixer fix --dry-run",
+ "./vendor/bin/psalm --threads=8",
+ "./vendor/bin/phpstan analyse"
+ ],
+ "test": [
+ "clear",
+ "./vendor/bin/phpunit",
+ "./vendor/bin/infection --threads=8"
+ ]
+ },
+ "support": {
+ "issues": "https://github.com/timacdonald/multiformat-response-objects/issues",
+ "source": "https://github.com/timacdonald/multiformat-response-objects/releases/latest",
+ "docs": "https://github.com/timacdonald/multiformat-response-objects/blob/master/readme.md"
}
}
diff --git a/infection.json.dist b/infection.json.dist
new file mode 100644
index 0000000..eda367b
--- /dev/null
+++ b/infection.json.dist
@@ -0,0 +1,13 @@
+{
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "logs": {
+ "text": "infection.log"
+ },
+ "mutators": {
+ "@default": true
+ }
+}
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..cddc7d2
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,7 @@
+parameters:
+ checkMissingIterableValueType: false
+ level: max
+ paths:
+ - src
+ - tests
+ ignoreErrors:
diff --git a/phpunit.xml b/phpunit.xml
deleted file mode 100644
index 5421b2a..0000000
--- a/phpunit.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
- ./tests/
-
-
-
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..7dbde61
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..fbfa6a8
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme.md b/readme.md
index 41b5abb..327fd12 100644
--- a/readme.md
+++ b/readme.md
@@ -1,10 +1,10 @@
-# Multi-format Response Object for Laravel
+# Multiformat response object for Laravel
-[](https://packagist.org/packages/timacdonald/multiformat-response-objects) [](https://packagist.org/packages/timacdonald/multiformat-response-objects) [](https://packagist.org/packages/timacdonald/multiformat-response-objects)
+[](https://packagist.org/packages/timacdonald/multiformat-response-objects) [](https://packagist.org/packages/timacdonald/multiformat-response-objects)
In some situations you may want to support multiple return formats (HTML, JSON, CSV, XLSX) for the one endpoint and controller. This package gives you a base class that helps you return different formats of the same data. It supports specifying the return format as a file extension or as an `Accept` header. It also allows you to have shared and format specific logic, all while sharing the same route and controller.
-## Installation
+## Installationasdf
You can install using [composer](https://getcomposer.org/) from [Packagist](https://packagist.org/packages/timacdonald/multiformat-response-objects)
@@ -14,9 +14,11 @@ $ composer require timacdonald/multiformat-response-objects
## Getting started
-This package is designed to help if you have ever created a controller that looks like this...
+This package is designed to help if you have ever created two different controllers just to provide different formats (HTML / JSON) but the controllers have a lot of shared logic, or if you have ever created a controller that looks like this...
```php
+ ['mpga', 'mp2', 'mp2a', 'mp3', 'm2a', 'm3a'],
```
@@ -205,6 +217,8 @@ This package will resolve the first match, i.e. `mpga` as the format type. If yo
### In the controller
```php
+ 'users.index',
'uses' => 'UserController@index',
@@ -261,6 +279,8 @@ This route will be able to respond to the following urls and formats in the resp
That's cool. Not everyone loves it. You don't have to use the `make` method. Just add your own contructor and set your class attributes as you like!
```php
+value = $value;
+ }
+
+ public function value(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/src/BaseMultiformatResponse.php b/src/BaseMultiformatResponse.php
new file mode 100644
index 0000000..4d3ea26
--- /dev/null
+++ b/src/BaseMultiformatResponse.php
@@ -0,0 +1,12 @@
+value = $value;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function value(): array
+ {
+ return $this->value;
+ }
+}
diff --git a/src/ExplicitExtension.php b/src/ExplicitExtension.php
new file mode 100644
index 0000000..f4ec2ab
--- /dev/null
+++ b/src/ExplicitExtension.php
@@ -0,0 +1,26 @@
+extension = $extension;
+ }
+
+ public function guess(Request $request): string
+ {
+ return $this->extension;
+ }
+}
diff --git a/src/Extension.php b/src/Extension.php
index 259f5bf..15c9c2a 100644
--- a/src/Extension.php
+++ b/src/Extension.php
@@ -1,33 +1,44 @@
formatOverrides = $formatOverrides;
+ $this->guessers = $guessers;
}
public function parse(Request $request): ?string
{
- return $this->urlExtension($request) ?? $this->acceptHeaderExtension($request);
- }
+ $extension = Collection::make($this->guessers)
+ ->reduce(static function (?string $carry, ExtensionGuesser $guesser) use ($request): ?string {
+ return $carry ?? $guesser->guess($request);
+ }, null);
- private function acceptHeaderExtension(Request $request) : ?string
- {
- return (new MimeExtension($this->formatOverrides))->parse($request);
- }
+ if ($extension === null) {
+ return null;
+ }
- private function urlExtension(Request $request): ?string
- {
- return (new UrlExtension)->parse($request);
+ assert(is_string($extension));
+
+ return $extension;
}
}
diff --git a/src/Method.php b/src/Method.php
new file mode 100644
index 0000000..17e2ab1
--- /dev/null
+++ b/src/Method.php
@@ -0,0 +1,54 @@
+extension = $extension;
+ }
+
+ public function parse(Request $request, object $response, ApiFallbackExtension $fallbackExtension): callable
+ {
+ $extension = $this->extension->parse($request) ?? $fallbackExtension->value();
+
+ return self::method($response, self::name($response, $extension));
+ }
+
+ private static function method(object $response, string $name): callable
+ {
+ $method = [$response, $name];
+
+ assert(is_callable($method));
+
+ return $method;
+ }
+
+ private static function name(object $response, string $extension): string
+ {
+ $name = 'to'.Str::studly($extension).'Response';
+
+ if (! method_exists($response, $name)) {
+ throw new Exception('Method '.get_class($response).'::'.$name.'() does not exist');
+ }
+
+ return $name;
+ }
+}
diff --git a/src/MimeExtension.php b/src/MimeExtension.php
index 88b31d9..511c8d7 100644
--- a/src/MimeExtension.php
+++ b/src/MimeExtension.php
@@ -1,49 +1,57 @@
overrides = $overrides;
+ $this->guesser = $guesser;
- $this->mimeTypes = new MimeTypes;
+ $this->mimeTypes = $mimeTypes;
}
- public function parse(Request $request): ?string
+ public function guess(Request $request): ?string
{
- foreach ($request->getAcceptableContentTypes() as $contentType) {
- $extension = $this->getOverride($contentType) ?? $this->getExtension($contentType);
-
- if ($extension !== null) {
- return $extension;
- }
+ $extension = Collection::make($request->getAcceptableContentTypes())
+ ->map(function (string $contentType): ?string {
+ return $this->findContentTypeExtension($contentType);
+ })->first(static function (?string $extension): bool {
+ return $extension !== null;
+ });
+
+ if ($extension === null) {
+ return null;
}
- return $request->format(null);
- }
+ assert(is_string($extension));
- private function getExtension(string $contentType): ?string
- {
- return $this->mimeTypes->getExtensions($contentType)[0] ?? null;
+ return $extension;
}
- private function getOverride(string $contentType): ?string
+ private function findContentTypeExtension(string $contentType): ?string
{
- return $this->overrides[$contentType] ?? null;
+ return $this->mimeTypes->value()[$contentType] ??
+ $this->guesser->getExtensions($contentType)[0] ??
+ null;
}
}
diff --git a/src/Multiformat.php b/src/Multiformat.php
new file mode 100644
index 0000000..e463c36
--- /dev/null
+++ b/src/Multiformat.php
@@ -0,0 +1,102 @@
+with($data);
+ }
+
+ /**
+ * @return static
+ */
+ public function with(array $data)
+ {
+ $this->data = array_merge($this->data, $data);
+
+ return $this;
+ }
+
+ /**
+ * @return static
+ */
+ public function withApiFallbackExtension(string $extension)
+ {
+ $this->apiFallbackExtension = new ApiFallbackExtension($extension);
+
+ return $this;
+ }
+
+ /**
+ * @psalm-suppress MixedInferredReturnType
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ */
+ public function toResponse($request)
+ {
+ $app = Application::getInstance();
+
+ $method = $app->make(Method::class);
+ assert($method instanceof Method);
+
+ $fallback = $this->apiFallbackExtension;
+
+ if ($fallback === null) {
+ $fallback = $app->make(ApiFallbackExtension::class);
+
+ assert($fallback instanceof ApiFallbackExtension);
+ }
+
+ $callable = $method->parse($request, $this, $fallback);
+
+ $response = $app->call($callable, ['request' => $request]);
+
+ while ($response instanceof Responsable) {
+ $response = $response->toResponse($request);
+ }
+
+ return $response;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function __get(string $key)
+ {
+ if (array_key_exists($key, $this->data)) {
+ return $this->data[$key];
+ }
+
+ throw new Exception('Accessing undefined attribute '.static::class.'::'.$key);
+ }
+}
diff --git a/src/MultiformatResponseServiceProvider.php b/src/MultiformatResponseServiceProvider.php
new file mode 100644
index 0000000..5e5b754
--- /dev/null
+++ b/src/MultiformatResponseServiceProvider.php
@@ -0,0 +1,43 @@
+app->bind(CustomMimeTypes::class, static function (): CustomMimeTypes {
+ return new CustomMimeTypes([]);
+ });
+
+ $this->app->bind(ApiFallbackExtension::class, static function (): ApiFallbackExtension {
+ return new ApiFallbackExtension('html');
+ });
+
+ $this->app->bind(UrlExtension::class, static function (): UrlExtension {
+ return new UrlExtension([]);
+ });
+
+ $this->app->bind(ExtensionContract::class, static function (Application $app): ExtensionContract {
+ $urlExtension = $app->make(UrlExtension::class);
+ $mimeExtension = $app->make(MimeExtension::class);
+ $fallbackExtension = $app->make(ApiFallbackExtension::class);
+
+ assert($urlExtension instanceof UrlExtension);
+ assert($mimeExtension instanceof MimeExtension);
+ assert($fallbackExtension instanceof ApiFallbackExtension);
+
+ return new Extension([
+ $urlExtension,
+ $mimeExtension,
+ ]);
+ });
+ }
+}
diff --git a/src/Response.php b/src/Response.php
deleted file mode 100644
index 119c72d..0000000
--- a/src/Response.php
+++ /dev/null
@@ -1,86 +0,0 @@
-with($data);
- }
-
- public function with($data): self
- {
- $this->data = array_merge($this->data, $data);
-
- return $this;
- }
-
- public function withDefaultFormat(string $format): self
- {
- $this->defaultFormat = $format;
-
- return $this;
- }
-
- public function withFormatOverrides(array $formatOverrides): self
- {
- $this->formatOverrides = array_merge($this->formatOverrides, $formatOverrides);
-
- return $this;
- }
-
- /**
- * @param \Illuminate\Http\Request $request
- * @return \Symfony\Component\HttpFoundation\Response
- */
- public function toResponse($request)
- {
- return Container::getInstance()->call([$this, $this->responseMethod($request)], [
- 'request' => $request,
- ]);
- }
-
- private function responseMethod(Request $request): string
- {
- return 'to'.Str::studly($this->extension($request)).'Response';
- }
-
- private function extension(Request $request): string
- {
- return (new Extension($this->formatOverrides))->parse($request) ?? $this->defaultFormat;
- }
-
- /**
- * @return mixed
- */
- public function __get(string $key)
- {
- if (array_key_exists($key, $this->data)) {
- return $this->data[$key];
- }
-
- throw new Exception('Accessing undefined attribute '.static::class.'::'.$key);
- }
-}
diff --git a/src/UrlExtension.php b/src/UrlExtension.php
index 85dcce9..c2957a5 100644
--- a/src/UrlExtension.php
+++ b/src/UrlExtension.php
@@ -1,29 +1,46 @@
extension($this->filename($request));
+ $this->formatOverrides = $formatOverrides;
}
- private function extension(string $filename): ?string
+ public function guess(Request $request): ?string
{
+ $filename = Arr::last(explode('/', $request->path()));
+
+ assert(is_string($filename));
+
if (! Str::contains($filename, '.')) {
return null;
}
- return Arr::last(explode('.', $filename));
- }
+ $extension = Arr::last(explode('.', $filename));
- private function filename(Request $request): string
- {
- return Arr::last(explode('/', $request->path()));
+ assert(is_string($extension));
+
+ return $extension;
}
}
diff --git a/tests/MultFormatResponseTest.php b/tests/MultFormatResponseTest.php
deleted file mode 100644
index e14c2ae..0000000
--- a/tests/MultFormatResponseTest.php
+++ /dev/null
@@ -1,298 +0,0 @@
-withoutExceptionHandling();
- }
-
- public function test_can_instantiate_instance_with_make_and_data_is_available()
- {
- $instance = TestResponse::make(['property' => 'expected']);
-
- $this->assertSame('expected', $instance->property);
- }
-
- public function test_can_add_data_using_with_and_retrieve_with_magic_get()
- {
- $instance = (new TestResponse)->with(['property' => 'expected value']);
-
- $this->assertSame('expected value', $instance->property);
- }
-
- public function test_access_to_non_existent_attribute_throws_exception()
- {
- $this->expectException(Exception::class);
- $this->expectExceptionMessage('Accessing undefined attribute Tests\TestResponse::not_set');
-
- (new TestResponse)->not_set;
- }
-
- public function test_with_merges_data()
- {
- $instance = new TestResponse;
- $instance->with(['property_1' => 'expected value 1']);
- $instance->with(['property_2' => 'expected value 2']);
-
- $this->assertSame('expected value 1', $instance->property_1);
- $this->assertSame('expected value 2', $instance->property_2);
- }
-
- public function test_with_overrides_when_passing_duplicate_key()
- {
- $instance = new TestResponse;
- $instance->with(['property' => 1]);
- $instance->with(['property' => 2]);
-
- $this->assertSame(2, $instance->property);
- }
-
- public function test_is_defaults_to_html_format()
- {
- Route::get('location', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location');
-
- $response->assertOk();
- $this->assertSame('expected html response', $response->content());
- }
-
- public function test_responds_to_extension_in_the_route()
- {
- Route::get('location.csv', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location.csv');
-
- $response->assertOk();
- $this->assertSame('expected csv response', $response->content());
- }
-
- public function test_responds_to_accept_header()
- {
- Route::get('location', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location', [
- 'Accept' => 'application/json',
- ]);
-
- $response->assertOk();
- $this->assertSame('expected json response', $response->content());
- }
-
- public function test_responds_to_first_matching_accepts_header()
- {
- Route::get('location', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location', [
- 'Accept' => 'text/csv, text/css',
- ]);
-
- $response->assertOk();
- $this->assertSame('expected csv response', $response->content());
- }
-
- public function test_responds_to_a_more_obscure_accept_header()
- {
- Route::get('location', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location', [
- 'Accept' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- ]);
-
- $response->assertOk();
- $this->assertSame('expected xlsx response', $response->content());
- }
-
- public function test_last_dot_segement_is_used_as_the_extension_type()
- {
- Route::get('websites/{domain}{format}', function () {
- return new TestResponse;
- })->where('format', '.json');
-
- $response = $this->get('websites/timacdonald.me.json');
-
- $response->assertOk();
- $this->assertSame('expected json response', $response->content());
- }
-
- public function test_file_extension_takes_precendence_over_accept_header()
- {
- Route::get('location{format}', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location.csv', [
- 'Accept' => 'application/json',
- ]);
-
- $response->assertOk();
- $this->assertSame('expected csv response', $response->content());
- }
-
- public function test_root_domain_returns_html_by_default()
- {
- $this->app->config->set('app.url', 'http://timacdonald.me');
- Route::get('', function () {
- return new TestResponse;
- });
-
- $response = $this->get('');
-
- $response->assertOk();
- $this->assertSame('expected html response', $response->content());
- }
-
- public function test_root_domain_response_to_other_formats()
- {
- $this->app->config->set('app.url', 'http://timacdonald.me');
- Route::get('.csv', function () {
- return new TestResponse;
- });
-
- $response = $this->get('.csv');
-
- $response->assertOk();
- $this->assertSame('expected csv response', $response->content());
- }
-
- public function test_query_string_has_no_impact()
- {
- Route::get('location', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location?format=.csv');
-
- $response->assertOk();
- $this->assertSame('expected html response', $response->content());
- }
-
- public function test_container_passes_request_into_format_methods()
- {
- Route::get('location.csv', function () {
- return new class extends Response {
- public function toCsvResponse($request) {
- return $request->query('parameter');
- }
- };
- });
-
- $response = $this->get('location.csv?parameter=expected%20value');
-
- $response->assertOk();
- $this->assertSame('expected value', $response->content());
- }
-
- public function test_container_resolves_dependencies_in_format_methods()
- {
- $this->app->bind(stdClass::class, function () {
- $instance = new stdClass;
- $instance->property = 'expected value';
- return $instance;
- });
- Route::get('location.csv', function () {
- return new class extends Response {
- public function toCsvResponse(stdClass $stdClass) {
- return $stdClass->property;
- }
- };
- });
-
- $response = $this->get('location.csv');
-
- $response->assertOk();
- $this->assertSame('expected value', $response->content());
- }
-
- public function test_can_set_default_response_format()
- {
- Route::get('location', function () {
- return TestResponse::make()->withDefaultFormat('csv');
- });
-
- $response = $this->get('location', ['Accept' => null]);
-
- $response->assertOk();
- $this->assertSame('expected csv response', $response->content());
- }
-
- public function test_exception_is_throw_if_no_response_method_exists()
- {
- $this->expectExceptionMessage('Method Tests\TestResponse::toMp3Response() does not exist');
-
- Route::get('location{format}', function () {
- return new TestResponse;
- });
-
- $response = $this->get('location.mp3');
- }
-
- public function test_url_html_format_is_used_when_the_default_has_another_value()
- {
- Route::get('location{format}', function () {
- return (new TestResponse)->withDefaultFormat('csv');
- });
-
- $response = $this->get('location.html');
-
- $response->assertOk();
- $this->assertSame('expected html response', $response->content());
- }
-
- public function test_can_override_formats()
- {
- Route::get('location', function () {
- return (new TestResponse)->withFormatOverrides(['text/csv' => 'json']);
- });
-
- $response = $this->get('location', ['Accept' => 'text/csv']);
-
- $response->assertOk();
- $this->assertSame('expected json response', $response->content());
- }
-}
-
-class TestResponse extends Response
-{
- public function toHtmlResponse()
- {
- return 'expected html response';
- }
-
- public function toJsonResponse()
- {
- return 'expected json response';
- }
-
- public function toCsvResponse()
- {
- return 'expected csv response';
- }
-
- public function toXlsxResponse()
- {
- return 'expected xlsx response';
- }
-}
-
diff --git a/tests/MultiformatResponseTest.php b/tests/MultiformatResponseTest.php
new file mode 100644
index 0000000..c7174d5
--- /dev/null
+++ b/tests/MultiformatResponseTest.php
@@ -0,0 +1,388 @@
+withoutExceptionHandling();
+ }
+
+ protected function getPackageProviders($app)
+ {
+ return [
+ MultiformatResponseServiceProvider::class,
+ ];
+ }
+
+ public function testCanInstantiateInstanceWithMakeAndDataIsAvailable(): void
+ {
+ $instance = TestResponse::make(['property' => 'expected']);
+
+ $this->assertSame('expected', $instance->property);
+ }
+
+ public function testCanAddDataUsingWithAndRetrieveWithMagicGet(): void
+ {
+ $instance = (new TestResponse())->with(['property' => 'expected value']);
+
+ $this->assertSame('expected value', $instance->property);
+ }
+
+ public function testAccessToNonExistentAttributeThrowsException(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Accessing undefined attribute Tests\\TestResponse::not_set');
+
+ /**
+ * @psalm-suppress UndefinedMagicPropertyFetch
+ * @phpstan-ignore-next-line
+ */
+ (new TestResponse())->not_set;
+ }
+
+ public function testWithMergesData(): void
+ {
+ $instance = new TestResponse();
+ $instance->with(['property_1' => 'expected value 1']);
+ $instance->with(['property_2' => 'expected value 2']);
+
+ $this->assertSame('expected value 1', $instance->property_1);
+ $this->assertSame('expected value 2', $instance->property_2);
+ }
+
+ public function testWithOverridesWhenPassingDuplicateKey(): void
+ {
+ $instance = new TestResponse();
+ $instance->with(['property' => 'first']);
+ $instance->with(['property' => 'second']);
+
+ $this->assertSame('second', $instance->property);
+ }
+
+ public function testIsDefaultsToHtmlFormat(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location');
+
+ $response->assertOk();
+ $this->assertSame('expected html response', $response->content());
+ }
+
+ public function testRespondsToExtensionInTheRoute(): void
+ {
+ Route::get('location.csv', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location.csv');
+
+ $response->assertOk();
+ $this->assertSame('expected csv response', $response->content());
+ }
+
+ public function testRespondsToAcceptHeader(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location', [
+ 'Accept' => 'application/json',
+ ]);
+
+ $response->assertOk();
+ $this->assertSame('expected json response', $response->content());
+ }
+
+ public function testRespondsToFirstMatchingAcceptsHeader(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location', [
+ 'Accept' => 'text/csv, text/css',
+ ]);
+
+ $response->assertOk();
+ $this->assertSame('expected csv response', $response->content());
+ }
+
+ public function testRespondsToAMoreObscureAcceptHeader(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location', [
+ 'Accept' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ]);
+
+ $response->assertOk();
+ $this->assertSame('expected xlsx response', $response->content());
+ }
+
+ public function testLastDotSegementIsUsedAsTheExtensionType(): void
+ {
+ Route::get('websites/{domain}{format}', static function (): Responsable {
+ return new TestResponse();
+ })->where('format', '.json');
+
+ $response = $this->get('websites/timacdonald.me.json');
+
+ $response->assertOk();
+ $this->assertSame('expected json response', $response->content());
+ }
+
+ public function testFileExtensionTakesPrecendenceOverAcceptHeader(): void
+ {
+ Route::get('location{format}', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location.csv', [
+ 'Accept' => 'application/json',
+ ]);
+
+ $response->assertOk();
+ $this->assertSame('expected csv response', $response->content());
+ }
+
+ public function testRootDomainReturnsHtmlByDefault(): void
+ {
+ $this->config()->set('app.url', 'http://timacdonald.me');
+ Route::get('', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('');
+
+ $response->assertOk();
+ $this->assertSame('expected html response', $response->content());
+ }
+
+ public function testRootDomainResponseToOtherFormats(): void
+ {
+ $this->config()->set('app.url', 'http://timacdonald.me');
+ Route::get('.csv', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('.csv');
+
+ $response->assertOk();
+ $this->assertSame('expected csv response', $response->content());
+ }
+
+ public function testQueryStringHasNoImpact(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location?format=.csv');
+
+ $response->assertOk();
+ $this->assertSame('expected html response', $response->content());
+ }
+
+ public function testContainerPassesRequestIntoFormatMethods(): void
+ {
+ Route::get('location.csv', static function (): Responsable {
+ return new class() extends BaseMultiformatResponse {
+ public function toCsvResponse(Request $request): string
+ {
+ $query = $request->query('parameter');
+
+ assert(is_string($query));
+
+ return $query;
+ }
+ };
+ });
+
+ $response = $this->get('location.csv?parameter=expected%20value');
+
+ $response->assertOk();
+ $this->assertSame('expected value', $response->content());
+ }
+
+ public function testContainerResolvesDependenciesInFormatMethods(): void
+ {
+ $this->app->bind(stdClass::class, static function () {
+ $instance = new stdClass();
+ $instance->property = 'expected value';
+
+ return $instance;
+ });
+ Route::get('location.csv', static function (): Responsable {
+ return new class() extends BaseMultiformatResponse {
+ public function toCsvResponse(stdClass $stdClass): string
+ {
+ assert(is_string($stdClass->property));
+
+ return $stdClass->property;
+ }
+ };
+ });
+
+ $response = $this->get('location.csv');
+
+ $response->assertOk();
+ $this->assertSame('expected value', $response->content());
+ }
+
+ public function testCanSetDefaultResponseFormatForApis(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return TestResponse::make([])->withApiFallbackExtension('csv');
+ });
+
+ $response = $this->get('location', ['Accept' => null]);
+
+ $response->assertOk();
+ $this->assertSame('expected csv response', $response->content());
+ }
+
+ public function testExceptionIsThrowIfNoResponseMethodExists(): void
+ {
+ $this->expectExceptionMessage('Method Tests\\TestResponse::toMp3Response() does not exist');
+
+ Route::get('location{format}', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $this->get('location.mp3');
+ }
+
+ public function testUrlHtmlFormatIsUsedWhenTheDefaultHasAnotherValueForApis(): void
+ {
+ Route::get('location{format}', static function (): Responsable {
+ return (new TestResponse())->withApiFallbackExtension('csv');
+ });
+
+ $response = $this->get('location.html', ['Accept' => null]);
+
+ $response->assertOk();
+ $this->assertSame('expected html response', $response->content());
+ }
+
+ public function testCanOverrideFormats(): void
+ {
+ $this->app->bind(CustomMimeTypes::class, static function (): CustomMimeTypes {
+ return new CustomMimeTypes(['text/csv' => 'json']);
+ });
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location', ['Accept' => 'text/csv']);
+
+ $response->assertOk();
+ $this->assertSame('expected json response', $response->content());
+ }
+
+ public function testUntypedRequestVariableIsPassedThrough(): void
+ {
+ Route::get('location.csv', static function (): Responsable {
+ return new class() extends BaseMultiformatResponse {
+ use Multiformat;
+
+ public function toCsvResponse(Request $request): string
+ {
+ $query = $request->input('query');
+
+ assert(is_string($query));
+
+ return $query;
+ }
+ };
+ });
+
+ $response = $this->get('location.csv?query=expected query');
+
+ $this->assertSame('expected query', $response->content());
+ }
+
+ public function testOverridingFallbackExtensionGloballyForApis(): void
+ {
+ $this->app->bind(ApiFallbackExtension::class, static function (): ApiFallbackExtension {
+ return new ApiFallbackExtension('json');
+ });
+ Route::get('location', static function (): Responsable {
+ return new TestResponse();
+ });
+
+ $response = $this->get('location', ['Accept' => null]);
+
+ $response->assertOk();
+ $this->assertSame('expected json response', $response->content());
+ }
+
+ public function testItCanReturnNestedResponsables(): void
+ {
+ Route::get('location', static function (): Responsable {
+ return new class() implements Responsable {
+ use Multiformat;
+
+ public function toHtmlResponse(): Responsable
+ {
+ return new class() implements Responsable {
+ /**
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ */
+ public function toResponse($request)
+ {
+ return new Response('expected from nexted');
+ }
+ };
+ }
+ };
+ });
+
+ $response = $this->get('location');
+
+ $response->assertOk();
+ $this->assertSame('expected from nexted', $response->content());
+ }
+
+ private function config(): Repository
+ {
+ $config = $this->app->make('config');
+
+ assert($config instanceof Repository);
+
+ return $config;
+ }
+}
diff --git a/tests/TestResponse.php b/tests/TestResponse.php
new file mode 100644
index 0000000..51468db
--- /dev/null
+++ b/tests/TestResponse.php
@@ -0,0 +1,35 @@
+