Skip to content

Commit da0ed70

Browse files
committedAug 17, 2022
feat: Initial release
0 parents  commit da0ed70

19 files changed

+724
-0
lines changed
 

‎.editorconfig

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
; This file is for unifying the coding style for different editors and IDEs.
2+
; More information at http://editorconfig.org
3+
4+
root = true
5+
6+
[*]
7+
charset = utf-8
8+
indent_size = 4
9+
indent_style = tab
10+
end_of_line = lf
11+
insert_final_newline = true
12+
trim_trailing_whitespace = true
13+
14+
[*.md]
15+
trim_trailing_whitespace = false

‎.github/workflows/release.yml

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Release
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Tests"]
6+
types:
7+
- completed
8+
branches:
9+
- master
10+
- next
11+
- next-major
12+
- beta
13+
- alpha
14+
15+
jobs:
16+
release:
17+
name: Release
18+
runs-on: ubuntu-latest
19+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v2
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Create a release
27+
uses: cycjimmy/semantic-release-action@v2
28+
with:
29+
extra_plugins: |
30+
@semantic-release/changelog
31+
@semantic-release/git
32+
env:
33+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

‎.github/workflows/tests.yml

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
phpunit:
9+
name: PHPUnit on PHP v${{ matrix.php }}
10+
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
fail-fast: true
15+
matrix:
16+
php: [8.1]
17+
18+
steps:
19+
- name: Checkout code
20+
uses: actions/checkout@v2
21+
22+
- name: Setup PHP
23+
uses: shivammathur/setup-php@v2
24+
with:
25+
php-version: ${{ matrix.php }}
26+
tools: composer:v2
27+
coverage: none
28+
29+
- name: Validate composer.json and composer.lock
30+
run: composer validate
31+
32+
- name: Cache Composer packages
33+
id: composer-cache
34+
uses: actions/cache@v2
35+
with:
36+
path: vendor
37+
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
38+
restore-keys: |
39+
${{ runner.os }}-php-
40+
41+
- name: Install dependencies
42+
run: composer install --prefer-dist --no-progress
43+
44+
- name: Execute phpunit
45+
run: composer test -- --colors=always
46+
47+
php-cs-fixer:
48+
name: php-cs-fixer
49+
runs-on: ubuntu-latest
50+
steps:
51+
- name: Checkout code
52+
uses: actions/checkout@v2
53+
54+
- name: Setup PHP
55+
uses: shivammathur/setup-php@v2
56+
with:
57+
php-version: 8.1
58+
extensions: dom, curl, libxml, mbstring, zip
59+
tools: composer:v2
60+
coverage: none
61+
62+
- name: Cache Composer packages
63+
id: composer-cache
64+
uses: actions/cache@v2
65+
with:
66+
path: vendor
67+
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
68+
restore-keys: |
69+
${{ runner.os }}-php-
70+
71+
- name: Install dependencies
72+
run: composer install --prefer-dist --no-progress
73+
74+
- name: Execute php-cs-fixer
75+
run: composer cs-fix -- --dry-run --diff --using-cache=no
76+
77+
phpstan:
78+
name: PHPStan
79+
runs-on: ubuntu-latest
80+
steps:
81+
- name: Checkout code
82+
uses: actions/checkout@v2
83+
84+
- name: Setup PHP
85+
uses: shivammathur/setup-php@v2
86+
with:
87+
php-version: 8.1
88+
tools: composer:v2
89+
coverage: none
90+
91+
- name: Validate composer.json and composer.lock
92+
run: composer validate
93+
94+
- name: Cache Composer packages
95+
id: composer-cache
96+
uses: actions/cache@v2
97+
with:
98+
path: vendor
99+
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
100+
restore-keys: |
101+
${{ runner.os }}-php-
102+
103+
- name: Install dependencies
104+
run: composer install --prefer-dist --no-progress
105+
106+
- name: Execute phpstan
107+
run: composer phpstan

‎.gitignore

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Editors
2+
.idea/*
3+
!.idea/fileTemplates
4+
5+
# Dependencies
6+
/vendor/
7+
/composer.lock
8+
9+
# Build/coverage
10+
/build
11+
12+
# Cache
13+
.php-cs-fixer.cache
14+
**/.phpunit.result.cache
15+
16+
# Temporary
17+
tmp/
18+
19+
# Misc
20+
.DS_Store
21+
/*.log
22+
*.swp

‎.php-cs-fixer.dist.php

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
require __DIR__ . '/vendor/tenantcloud/php-cs-fixer-rule-sets/src/TenantCloud/PhpCsFixer/RuleSet/TenantCloudSet.php';
4+
5+
use PhpCsFixer\Config;
6+
use PhpCsFixer\Finder;
7+
use TenantCloud\PhpCsFixer\RuleSet\TenantCloudSet;
8+
9+
$finder = Finder::create()
10+
->in('src')
11+
->in('tests')
12+
->name('*.php')
13+
->notName('_*.php')
14+
->ignoreVCS(true);
15+
16+
return (new Config())
17+
->setFinder($finder)
18+
->setRiskyAllowed(true)
19+
->setIndent("\t")
20+
->setRules([
21+
...(new TenantCloudSet())->rules(),
22+
]);

‎.releaserc.yml

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
plugins:
2+
- '@semantic-release/commit-analyzer'
3+
- '@semantic-release/release-notes-generator'
4+
- '@semantic-release/changelog'
5+
- '@semantic-release/git'
6+
- '@semantic-release/github'

‎CONTRIBUTING.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Commands
2+
3+
Install dependencies:
4+
`docker run -it --rm -v $PWD:/app -w /app composer install`
5+
6+
Run tests:
7+
`docker run -it --rm -v $PWD:/app -w /app php:8.1-cli vendor/bin/pest`
8+
9+
Run php-cs-fixer on self:
10+
`docker run -it --rm -v $PWD:/app -w /app composer cs-fix`
11+
12+
Run phpstan on self:
13+
`docker run -it --rm -v $PWD:/app -w /app composer phpstan`

‎LICENSE.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Oleksandr Prypkhan
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Package name
2+
3+
Description

‎composer.json

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "good-php/laravel-integration",
3+
"description": "Integrates good-php packages into Laravel applications seamlessly",
4+
"license": "MIT",
5+
"authors": [
6+
{
7+
"name": "Alex Wells (Oleksandr Prypkhan)",
8+
"email": "autaut03@gmail.com"
9+
}
10+
],
11+
"require": {
12+
"php": ">=8.1",
13+
"good-php/serialization": "dev-alpha"
14+
},
15+
"require-dev": {
16+
"pestphp/pest": "^1.0",
17+
"php-cs-fixer/shim": "~3.8.0",
18+
"tenantcloud/php-cs-fixer-rule-sets": "~2.0.0",
19+
"phpstan/phpstan": "^1.0",
20+
"phpstan/phpstan-phpunit": "^1.0",
21+
"phpstan/phpstan-webmozart-assert": "^1.0",
22+
"phpstan/phpstan-mockery": "^1.0",
23+
"orchestra/testbench": "^7.6"
24+
},
25+
"autoload": {
26+
"psr-0": {
27+
"": "src/"
28+
}
29+
},
30+
"autoload-dev": {
31+
"psr-4": {
32+
"Tests\\": "tests/"
33+
}
34+
},
35+
"scripts": {
36+
"test": "./vendor/bin/pest",
37+
"cs-fix": "./vendor/bin/php-cs-fixer fix -v --show-progress=dots",
38+
"phpstan": "./vendor/bin/phpstan analyse",
39+
"testbench": "./vendor/bin/testbench"
40+
},
41+
"minimum-stability": "dev",
42+
"prefer-stable": true,
43+
"config": {
44+
"allow-plugins": {
45+
"pestphp/pest-plugin": true
46+
}
47+
},
48+
"extra": {
49+
"laravel": {
50+
"providers": [
51+
"GoodPhp\\LaravelIntegration\\GoodPhpServiceProvider"
52+
]
53+
}
54+
}
55+
}

‎phpstan.neon

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
includes:
2+
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
3+
- vendor/phpstan/phpstan-phpunit/extension.neon
4+
- vendor/phpstan/phpstan-webmozart-assert/extension.neon
5+
- vendor/phpstan/phpstan-mockery/extension.neon
6+
7+
parameters:
8+
level: max
9+
tmpDir: ./tmp/phpstan
10+
11+
paths:
12+
- src
13+
- tests
14+
15+
ignoreErrors:
16+
# There's no extension for that :(
17+
-
18+
message: '#Call to an undefined method Pest\\Expectation|Pest\\Support\\Extendable::#i'
19+
path: 'tests/*Test.php'

‎phpunit.xml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
4+
colors="true"
5+
>
6+
<testsuites>
7+
<testsuite name="Test Suite">
8+
<directory>./tests</directory>
9+
</testsuite>
10+
</testsuites>
11+
<coverage processUncoveredFiles="true">
12+
<include>
13+
<directory suffix=".php">./src</directory>
14+
</include>
15+
</coverage>
16+
</phpunit>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace GoodPhp\LaravelIntegration;
4+
5+
use GoodPhp\LaravelIntegration\Routing\SerializationInjectingControllerDispatcher;
6+
use GoodPhp\Reflection\Reflector\Reflector;
7+
use GoodPhp\Reflection\ReflectorBuilder;
8+
use GoodPhp\Serialization\Serializer;
9+
use GoodPhp\Serialization\SerializerBuilder;
10+
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Naming\BuiltInNamingStrategy;
11+
use Illuminate\Container\Container;
12+
use Illuminate\Contracts\Foundation\Application;
13+
use Illuminate\Routing\Contracts\ControllerDispatcher;
14+
use Illuminate\Support\ServiceProvider;
15+
16+
/**
17+
* Provides good-php packages.
18+
*/
19+
class GoodPhpServiceProvider extends ServiceProvider
20+
{
21+
/**
22+
* @inheritDoc
23+
*/
24+
public function register(): void
25+
{
26+
$this->app->singleton(
27+
Reflector::class,
28+
fn (Application $app) => (new ReflectorBuilder())
29+
->withCache($app->bootstrapPath('cache/vendor-good-php-reflection'))
30+
->build()
31+
);
32+
33+
$this->app->singleton(
34+
Serializer::class,
35+
fn (Container $container) => $container
36+
->make(SerializerBuilder::class)
37+
->namingStrategy(BuiltInNamingStrategy::SNAKE_CASE)
38+
->build()
39+
);
40+
41+
$this->app->singleton(ControllerDispatcher::class, SerializationInjectingControllerDispatcher::class);
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace GoodPhp\LaravelIntegration\Routing;
4+
5+
use Attribute;
6+
7+
/**
8+
* Denotes a controller parameter which the request body and query parameters should be deserialized into.
9+
*/
10+
#[Attribute(Attribute::TARGET_PARAMETER)]
11+
final class Input
12+
{
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace GoodPhp\LaravelIntegration\Routing;
4+
5+
use Exception;
6+
use GoodPhp\Reflection\Reflector\Reflection\FunctionParameterReflection;
7+
use GoodPhp\Reflection\Reflector\Reflection\MethodReflection;
8+
use GoodPhp\Serialization\Serializer;
9+
use GoodPhp\Serialization\TypeAdapter\Exception\CollectionItemMappingException;
10+
use GoodPhp\Serialization\TypeAdapter\Exception\MultipleMappingException;
11+
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\PropertyMappingException;
12+
use GoodPhp\Serialization\TypeAdapter\Primitive\PrimitiveTypeAdapter;
13+
use Illuminate\Container\Container;
14+
use Illuminate\Http\Request;
15+
use Illuminate\Routing\ControllerDispatcher;
16+
use Illuminate\Support\Arr;
17+
use Illuminate\Validation\ValidationException;
18+
use ReflectionParameter;
19+
20+
class SerializationInjectingControllerDispatcher extends ControllerDispatcher
21+
{
22+
public function __construct(
23+
Container $container,
24+
private readonly Serializer $serializer,
25+
private readonly \GoodPhp\Reflection\Reflector\Reflector $reflector,
26+
) {
27+
parent::__construct($container);
28+
}
29+
30+
/**
31+
* Attempt to transform the given parameter into a class instance.
32+
*
33+
* @param array $parameters
34+
* @param object $skippableValue
35+
*
36+
* @return mixed
37+
*/
38+
protected function transformDependency(ReflectionParameter $parameter, $parameters, $skippableValue)
39+
{
40+
if ($parameter->getAttributes(Input::class)) {
41+
$type = $this->reflector
42+
->forType($parameter->getDeclaringClass()->getName())
43+
->methods()
44+
->first(fn (MethodReflection $method) => $method->name() === $parameter->getDeclaringFunction()->getShortName())
45+
->parameters()
46+
->first(fn (FunctionParameterReflection $goodParameter) => $parameter->name === $goodParameter->name())
47+
->type();
48+
49+
try {
50+
return $this->serializer->adapter(PrimitiveTypeAdapter::class, $type)->deserialize(
51+
$this->container->make(Request::class)->input(),
52+
);
53+
} catch (PropertyMappingException|CollectionItemMappingException|MultipleMappingException $e) {
54+
throw ValidationException::withMessages(Arr::dot($this->extractErrors($e)));
55+
}
56+
}
57+
58+
return parent::transformDependency($parameter, $parameters, $skippableValue);
59+
}
60+
61+
private function extractErrors(Exception $e): array|string
62+
{
63+
if ($e instanceof PropertyMappingException) {
64+
return [
65+
$e->path => $this->extractErrors($e->getPrevious()),
66+
];
67+
}
68+
69+
if ($e instanceof CollectionItemMappingException) {
70+
return [
71+
$e->key => $this->extractErrors($e->getPrevious()),
72+
];
73+
}
74+
75+
if ($e instanceof MultipleMappingException) {
76+
return collect($e->exceptions)->mapWithKeys(function (PropertyMappingException|CollectionItemMappingException $e) {
77+
return [
78+
$e instanceof PropertyMappingException ? $e->path : $e->key => $this->extractErrors($e->getPrevious()),
79+
];
80+
})->all();
81+
}
82+
83+
return $e->getMessage();
84+
}
85+
}
+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
namespace Tests\Integration\Routing;
4+
5+
use Generator;
6+
use Illuminate\Support\Facades\Route;
7+
use Tests\TestCase;
8+
9+
class InjectionTest extends TestCase
10+
{
11+
protected function setUp(): void
12+
{
13+
parent::setUp();
14+
15+
Route::any('first', [StubController::class, 'first']);
16+
Route::any('second', [StubController::class, 'second']);
17+
}
18+
19+
/**
20+
* @dataProvider injectsInputProvider
21+
*/
22+
public function testInjectsInput(string $endpoint, array $input, array $expectedResponse): void
23+
{
24+
$this->postJson($endpoint, $input)
25+
->assertOk()
26+
->assertJson($expectedResponse);
27+
}
28+
29+
public function injectsInputProvider(): Generator
30+
{
31+
yield [
32+
'first',
33+
[
34+
'first_key' => 123,
35+
'secondKey' => '2020-01-01 00:00:00',
36+
],
37+
[
38+
'first' => 123,
39+
'second' => '2020-01-01',
40+
],
41+
];
42+
43+
yield [
44+
'first',
45+
[
46+
'first_key' => null,
47+
'secondKey' => '2020-01-01',
48+
],
49+
[
50+
'first' => null,
51+
'second' => '2020-01-01',
52+
],
53+
];
54+
55+
yield [
56+
'second',
57+
[],
58+
[],
59+
];
60+
61+
yield [
62+
'second',
63+
[
64+
[
65+
'first_key' => null,
66+
'secondKey' => null,
67+
],
68+
],
69+
[
70+
[
71+
'first' => null,
72+
'second' => null,
73+
],
74+
],
75+
];
76+
77+
yield [
78+
'second',
79+
[
80+
[
81+
'first_key' => 123,
82+
'secondKey' => '2020-01-01',
83+
],
84+
],
85+
[
86+
[
87+
'first' => 123,
88+
'second' => '2020-01-01',
89+
],
90+
],
91+
];
92+
}
93+
94+
/**
95+
* @dataProvider failsWithValidationProvider
96+
*/
97+
public function testFailsWithValidation(string $endpoint, array $input, array $expectedErrors): void
98+
{
99+
$this->postJson($endpoint, $input)
100+
->assertUnprocessable()
101+
->assertJsonValidationErrors($expectedErrors);
102+
}
103+
104+
public function failsWithValidationProvider(): Generator
105+
{
106+
yield [
107+
'first',
108+
[
109+
'secondKey' => '2020-01-01',
110+
],
111+
[
112+
'first_key' => 'Missing value',
113+
],
114+
];
115+
116+
yield [
117+
'first',
118+
[],
119+
[
120+
'first_key' => 'Missing value',
121+
'secondKey' => 'Missing value',
122+
],
123+
];
124+
125+
yield [
126+
'first',
127+
[
128+
'first_key' => 'string',
129+
],
130+
[
131+
'first_key' => "Expected value of type 'int', but got 'string'",
132+
],
133+
];
134+
135+
yield [
136+
'first',
137+
[
138+
'secondKey' => 'not a date',
139+
],
140+
[
141+
'secondKey' => 'Failed to parse time string (not a date) at position 0 (n): The timezone could not be found in the database',
142+
],
143+
];
144+
145+
yield [
146+
'second',
147+
[
148+
[
149+
],
150+
],
151+
[
152+
'0.first_key' => 'Missing value',
153+
'0.secondKey' => 'Missing value',
154+
],
155+
];
156+
157+
yield [
158+
'second',
159+
[
160+
[
161+
'first_key' => 'str',
162+
'secondKey' => [
163+
'item',
164+
],
165+
],
166+
],
167+
[
168+
'0.first_key' => "Expected value of type 'int', but got 'string'",
169+
'0.secondKey' => "Expected value of type 'string', but got 'array'",
170+
],
171+
];
172+
}
173+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Tests\Integration\Routing;
4+
5+
use DateTime;
6+
use GoodPhp\LaravelIntegration\Routing\Input;
7+
use Illuminate\Http\Request;
8+
use Tests\Stubs\Data;
9+
10+
class StubController
11+
{
12+
/**
13+
* @param Data<DateTime> $body
14+
*/
15+
public function first(
16+
Request $request,
17+
#[Input] Data $body,
18+
): array {
19+
return [
20+
'first' => $body->firstKey,
21+
'second' => $body->second->format('Y-m-d'),
22+
];
23+
}
24+
25+
/**
26+
* @param array<Data<DateTime|null>> $body
27+
*/
28+
public function second(
29+
Request $request,
30+
#[Input] array $body,
31+
): array {
32+
return array_map(fn (Data $data) => [
33+
'first' => $data->firstKey,
34+
'second' => $data->second?->format('Y-m-d'),
35+
], $body);
36+
}
37+
}

‎tests/Stubs/Data.php

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Tests\Stubs;
4+
5+
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Naming\SerializedName;
6+
7+
/**
8+
* @template T
9+
*/
10+
class Data
11+
{
12+
/**
13+
* @param T $second
14+
*/
15+
public function __construct(
16+
public readonly ?int $firstKey,
17+
#[SerializedName('secondKey')]
18+
public readonly mixed $second,
19+
) {
20+
}
21+
}

‎tests/TestCase.php

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Tests;
4+
5+
use GoodPhp\LaravelIntegration\GoodPhpServiceProvider;
6+
use Orchestra\Testbench\TestCase as BaseTestCase;
7+
8+
class TestCase extends BaseTestCase
9+
{
10+
/**
11+
* @inheritDoc
12+
*/
13+
protected function getPackageProviders($app): array
14+
{
15+
return [
16+
GoodPhpServiceProvider::class,
17+
];
18+
}
19+
}

0 commit comments

Comments
 (0)
Please sign in to comment.