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 -[![Latest Stable Version](https://poser.pugx.org/timacdonald/multiformat-response-objects/v/stable)](https://packagist.org/packages/timacdonald/multiformat-response-objects) [![Total Downloads](https://poser.pugx.org/timacdonald/multiformat-response-objects/downloads)](https://packagist.org/packages/timacdonald/multiformat-response-objects) [![License](https://poser.pugx.org/timacdonald/multiformat-response-objects/license)](https://packagist.org/packages/timacdonald/multiformat-response-objects) +[![Total Downloads](https://poser.pugx.org/timacdonald/multiformat-response-objects/downloads)](https://packagist.org/packages/timacdonald/multiformat-response-objects) [![License](https://poser.pugx.org/timacdonald/multiformat-response-objects/license)](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 @@ +