Merge branch 'main' into feat/matchfunding
This commit is contained in:
@@ -1,17 +1,25 @@
|
||||
name: PHP CS Fixer
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
env:
|
||||
PHP_VERSION: '8.3'
|
||||
PHP_EXTENSIONS: pdo_mysql
|
||||
|
||||
jobs:
|
||||
php-cs-fixer:
|
||||
runs-on: "ubuntu-latest"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Setup PHP 8.3
|
||||
uses: shivammathur/setup-php@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: php-actions/composer@v6
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: php-cs-fixer:3.64
|
||||
php_version: ${{ env.PHP_VERSION }}
|
||||
php_extensions: ${{ env.PHP_EXTENSIONS }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run PHP CS Fixer
|
||||
run: php-cs-fixer check
|
||||
- name: PHP CS Fixer
|
||||
run: php ./vendor/bin/php-cs-fixer check
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
name: PHPUnit
|
||||
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
PHP_VERSION: '8.3'
|
||||
PHP_EXTENSIONS: pdo_mysql intl
|
||||
|
||||
jobs:
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11.2
|
||||
@@ -19,12 +28,15 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: php-actions/composer@v6
|
||||
with:
|
||||
php_version: ${{ env.PHP_VERSION }}
|
||||
php_extensions: ${{ env.PHP_EXTENSIONS }}
|
||||
|
||||
- name: PHPUnit
|
||||
uses: php-actions/phpunit@v4
|
||||
env:
|
||||
DATABASE_URL: mysql://root:goteo@127.0.0.1:${{ job.services.mariadb.ports['3306'] }}/goteo
|
||||
with:
|
||||
php_version: 8.3
|
||||
php_extensions: pdo_mysql
|
||||
php_version: ${{ env.PHP_VERSION }}
|
||||
php_extensions: ${{ env.PHP_EXTENSIONS }}
|
||||
configuration: phpunit.xml.dist
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# The v4 API
|
||||
This repository holds the code for the Goteo **v4** API.
|
||||
|
||||
> **NOTE**: Review the trusted certificates by OpenSSL. See: https://github.com/GoteoFoundation/v4/issues/43
|
||||
|
||||
## Installation
|
||||
This application requires [Docker](https://docs.docker.com/get-docker/) and the [Docker Compose](https://docs.docker.com/compose/install/) plugin.
|
||||
|
||||
@@ -31,7 +33,7 @@ UID=1001
|
||||
GID=1001
|
||||
|
||||
APP_HTTP_PORT=8080
|
||||
APP_HTTPS_PORT=8433
|
||||
APP_HTTPS_PORT=8443
|
||||
```
|
||||
|
||||
Then feed your custom env vars to Compose:
|
||||
@@ -41,7 +43,7 @@ docker compose --env-file .env.local up -d --build
|
||||
|
||||
- Option B. Passing the variables through the shell.
|
||||
```shell
|
||||
export APP_HTTP_PORT=8080 && export APP_HTTPS_PORT=8433
|
||||
export APP_HTTP_PORT=8080 && export APP_HTTPS_PORT=8443
|
||||
|
||||
# Dynamic user and group id
|
||||
export UID=$(id -u) && export GID=$(id -g)
|
||||
@@ -73,7 +75,7 @@ bin/docker php bin/console app:gateways:setup
|
||||
|
||||
## Usage
|
||||
|
||||
The app should be live at [http://localhost:8091](http://localhost:8091) (or your specified ports). Keep in mind that the API address is [/v4](http://localhost:8091/v4).
|
||||
The app should be live at [http://localhost:8090](http://localhost:8090) (or your specified ports). Keep in mind that the API address is [/v4](http://localhost:8090/v4).
|
||||
|
||||
You can access a real-time build of the OpenAPI spec file for v4 at [http://localhost:8090/v4/docs.json](http://localhost:8090/v4/docs.json), to be used, for example, with API development suites such as Hoppscotch. This file will be up to date with most of your latest changes.
|
||||
|
||||
@@ -83,6 +85,7 @@ For quick Docker access you can use the `bin/docker` shortcut to quickly `exec`
|
||||
|
||||
- Login to mysql CLI: `bin/docker mariadb mysql -u goteo -pgoteo goteo`
|
||||
- Debug the symfony services: `bin/docker php bin/console debug:container`
|
||||
- List app custom commands: `bin/docker php bin/console list app`
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+9
-9
@@ -7,22 +7,22 @@
|
||||
"php": ">=8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"api-platform/core": "^3.2",
|
||||
"api-platform/doctrine-orm": "^4.0",
|
||||
"api-platform/symfony": "^4.0",
|
||||
"beberlei/doctrineextensions": "^1.4",
|
||||
"brick/math": "^0.11.0",
|
||||
"brick/money": "^0.8.1",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/doctrine-bundle": "^2.12",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^2.19",
|
||||
"doctrine/doctrine-bundle": "^2.13",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"doctrineencryptbundle/doctrine-encrypt-bundle": "^5.4",
|
||||
"jolicode/automapper": "^9.2",
|
||||
"nelmio/cors-bundle": "^2.3",
|
||||
"phpdocumentor/reflection-docblock": "^5.3",
|
||||
"phpstan/phpdoc-parser": "^1.24",
|
||||
"stof/doctrine-extensions-bundle": "^1.11",
|
||||
"stof/doctrine-extensions-bundle": "^1.13",
|
||||
"stripe/stripe-php": "^15.0",
|
||||
"subiabre/doctrine-snowflakes": "^2.0",
|
||||
"symfony/asset": "6.4.*",
|
||||
"symfony/cache": "6.4.*",
|
||||
"symfony/console": "6.4.*",
|
||||
@@ -91,12 +91,12 @@
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "3.64",
|
||||
"friendsofphp/php-cs-fixer": "^3.68",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/browser-kit": "6.4.*",
|
||||
"symfony/css-selector": "6.4.*",
|
||||
"symfony/maker-bundle": "^1.51",
|
||||
"symfony/phpunit-bridge": "^7.0",
|
||||
"zenstruck/foundry": "^1.36"
|
||||
"symfony/phpunit-bridge": "^7.2",
|
||||
"zenstruck/foundry": "^2.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1978
-1043
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -7,11 +7,11 @@ return [
|
||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Ambta\DoctrineEncryptBundle\AmbtaDoctrineEncryptBundle::class => ['all' => true],
|
||||
AutoMapper\Symfony\Bundle\AutoMapperBundle::class => ['all' => true],
|
||||
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
|
||||
];
|
||||
|
||||
@@ -30,9 +30,7 @@ api_platform:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
extra_properties:
|
||||
standard_put: true
|
||||
rfc_7807_compliant_errors: true
|
||||
event_listeners_backward_compatibility_layer: false
|
||||
keep_legacy_inflector: false
|
||||
use_symfony_listeners: true
|
||||
swagger:
|
||||
api_keys:
|
||||
access_token:
|
||||
|
||||
@@ -22,7 +22,11 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
|
||||
gedmo_translatable:
|
||||
type: attribute
|
||||
prefix: Gedmo\Translatable\Entity
|
||||
dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Translatable/Entity"
|
||||
is_bundle: false
|
||||
gedmo_loggable:
|
||||
type: attribute
|
||||
prefix: Gedmo\Loggable\Entity
|
||||
@@ -31,6 +35,8 @@ doctrine:
|
||||
dql:
|
||||
string_functions:
|
||||
instr: DoctrineExtensions\Query\Mysql\Instr
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
|
||||
@@ -18,6 +18,9 @@ framework:
|
||||
php_errors:
|
||||
log: true
|
||||
|
||||
annotations:
|
||||
enabled: false
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
|
||||
# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc
|
||||
stof_doctrine_extensions:
|
||||
default_locale: en_US
|
||||
|
||||
default_locale: '%kernel.default_locale%'
|
||||
orm:
|
||||
default:
|
||||
translatable: true
|
||||
loggable: true
|
||||
timestampable: true
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
framework:
|
||||
uid:
|
||||
default_uuid_version: 7
|
||||
time_based_uuid_version: 7
|
||||
@@ -1,7 +1,5 @@
|
||||
when@dev: &dev
|
||||
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
|
||||
zenstruck_foundry:
|
||||
# Whether to auto-refresh proxies by default (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh)
|
||||
auto_refresh_proxies: true
|
||||
|
||||
when@test: *dev
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
tags: ['app.matchfunding.strategy.strategy']
|
||||
AutoMapper\Provider\ProviderInterface:
|
||||
tags: ['app.mapping.map_provider']
|
||||
AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface:
|
||||
tags: ['app.mapping.map_transformer']
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
@@ -65,6 +67,10 @@ services:
|
||||
arguments:
|
||||
- '%env(APP_SECRET)%'
|
||||
|
||||
App\Service\LocalizationService:
|
||||
arguments:
|
||||
- '%kernel.default_locale%'
|
||||
|
||||
App\Library\Benzina\Benzina:
|
||||
arguments:
|
||||
- !tagged 'app.lib.benzina.pump.pump'
|
||||
@@ -77,6 +83,7 @@ services:
|
||||
arguments:
|
||||
- '%kernel.cache_dir%'
|
||||
- !tagged 'app.mapping.map_provider'
|
||||
- !tagged 'app.mapping.map_transformer'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
php:
|
||||
volumes:
|
||||
# ./docker/php/ssl/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
- ./docker/php/conf.d/opcache.dev.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
|
||||
- ./docker/php/conf.d/xdebug.dev.ini:/usr/local/etc/php/conf.d/xdebug.ini:ro
|
||||
extra_hosts:
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
container_name: goteo-v4-php
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./docker/php/conf.d/openssl.ini:/usr/local/etc/php/conf.d/openssl.ini:ro
|
||||
- ./docker/php/conf.d/opcache.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
|
||||
|
||||
nginx:
|
||||
|
||||
@@ -17,13 +17,19 @@ RUN docker-php-ext-install \
|
||||
intl \
|
||||
zip
|
||||
|
||||
RUN docker-php-ext-install opcache
|
||||
|
||||
RUN apk --no-cache add $PHPIZE_DEPS
|
||||
RUN pecl install apcu \
|
||||
&& docker-php-ext-enable apcu
|
||||
|
||||
RUN curl --remote-name https://curl.se/ca/cacert.pem \
|
||||
&& mv cacert.pem /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY --from=composer:lts /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
|
||||
FROM base AS dev
|
||||
|
||||
RUN docker-php-ext-install opcache
|
||||
|
||||
RUN apk --no-cache add $PHPIZE_DEPS
|
||||
RUN pecl install xdebug \
|
||||
&& docker-php-ext-enable xdebug
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[openssl]
|
||||
openssl.cafile=/etc/ssl/certs/ca-certificates.crt
|
||||
@@ -0,0 +1 @@
|
||||
*.crt
|
||||
@@ -11,8 +11,10 @@ use App\Entity\Money;
|
||||
use App\Entity\Project\Project;
|
||||
use App\Entity\Tipjar;
|
||||
use App\Entity\User\User;
|
||||
use App\Mapping\Transformer\AccountingBalanceMapTransformer;
|
||||
use App\State\Accounting\AccountingStateProcessor;
|
||||
use App\State\Accounting\AccountingStateProvider;
|
||||
use AutoMapper\Attribute\MapFrom;
|
||||
|
||||
/**
|
||||
* v4 features an advanced economy model under the hood.
|
||||
@@ -41,18 +43,15 @@ class AccountingApiResource
|
||||
*/
|
||||
public string $currency;
|
||||
|
||||
/**
|
||||
* The money currently held by the Accounting.
|
||||
*/
|
||||
#[MapFrom(Accounting::class, transformer: AccountingBalanceMapTransformer::class)]
|
||||
public Money $balance;
|
||||
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public string $ownerClass;
|
||||
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public ?UserApiResource $user = null;
|
||||
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public ?ProjectApiResource $project = null;
|
||||
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public ?Tipjar $tipjar = null;
|
||||
|
||||
/**
|
||||
* The resource owning this Accounting.
|
||||
*
|
||||
@@ -70,8 +69,12 @@ class AccountingApiResource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The money currently held by the Accounting.
|
||||
*/
|
||||
public Money $balance;
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public ?UserApiResource $user = null;
|
||||
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public ?ProjectApiResource $project = null;
|
||||
|
||||
#[API\ApiProperty(readable: false, writable: false)]
|
||||
public ?Tipjar $tipjar = null;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ use App\Entity\Gateway\Checkout;
|
||||
use App\Gateway\CheckoutStatus;
|
||||
use App\Gateway\Link;
|
||||
use App\Gateway\Tracking;
|
||||
use App\Mapping\Transformer\GatewayNameMapTransformer;
|
||||
use App\State\ApiResourceStateProvider;
|
||||
use App\State\Gateway\CheckoutStateProcessor;
|
||||
use AutoMapper\Attribute\MapFrom;
|
||||
use AutoMapper\Attribute\MapTo;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,8 @@ class CheckoutApiResource
|
||||
* The desired Gateway to checkout with.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
#[MapFrom(property: 'gatewayName', transformer: GatewayNameMapTransformer::class)]
|
||||
#[MapTo(property: 'gatewayName', transformer: 'source.gateway.name')]
|
||||
public GatewayApiResource $gateway;
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,14 @@ class ProjectApiResource
|
||||
#[API\ApiProperty(writable: false)]
|
||||
public UserApiResource $owner;
|
||||
|
||||
/**
|
||||
* List of the available content locales.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
#[API\ApiProperty(writable: false)]
|
||||
public array $locales;
|
||||
|
||||
/**
|
||||
* Main title for the Project.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\AbstractPaginator;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\CountWalker;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
|
||||
|
||||
/**
|
||||
* Adds localization hints to translatable entity queries.
|
||||
*
|
||||
* @see https://github.com/doctrine-extensions/DoctrineExtensions/blob/main/doc/translatable.md#using-orm-query-hint
|
||||
*/
|
||||
final class LocalizedCollectionExtension implements QueryResultCollectionExtensionInterface
|
||||
{
|
||||
use LocalizedExtensionTrait;
|
||||
use PaginationExtensionTrait;
|
||||
|
||||
/**
|
||||
* Same priority as `api_platform.doctrine.orm.query_extension.pagination`.
|
||||
* This ensures other filters can have precedence over localization.
|
||||
*
|
||||
* @see https://api-platform.com/docs/core/extensions/#custom-doctrine-orm-extension
|
||||
*/
|
||||
public static function getDefaultPriority(): int
|
||||
{
|
||||
return -64;
|
||||
}
|
||||
|
||||
public function getResult(
|
||||
QueryBuilder $queryBuilder,
|
||||
?string $resourceClass = null,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): iterable {
|
||||
$query = $this->addLocalizationHints($queryBuilder, $this->getContextLanguages($context));
|
||||
|
||||
if (\count($queryBuilder->getAllAliases()) === 1) {
|
||||
$query->setHint(CountWalker::HINT_DISTINCT, false);
|
||||
}
|
||||
|
||||
$doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->shouldDoctrinePaginatorFetchJoinCollection($queryBuilder, $operation, $context));
|
||||
$doctrineOrmPaginator->setUseOutputWalkers($this->shouldDoctrinePaginatorUseOutputWalkers($queryBuilder, $operation, $context));
|
||||
|
||||
$isPartialEnabled = $this->pagination->isPartialEnabled($operation, $context);
|
||||
|
||||
if ($isPartialEnabled) {
|
||||
return new class($doctrineOrmPaginator) extends AbstractPaginator {};
|
||||
}
|
||||
|
||||
return new Paginator($doctrineOrmPaginator);
|
||||
}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
if (null === $pagination = $this->getPagination($queryBuilder, $operation, $context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$offset, $limit] = $pagination;
|
||||
|
||||
$queryBuilder
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function getPagination(QueryBuilder $queryBuilder, ?Operation $operation, array $context): ?array
|
||||
{
|
||||
$enabled = isset($context['graphql_operation_name']) ? $this->pagination->isGraphQlEnabled($operation, $context) : $this->pagination->isEnabled($operation, $context);
|
||||
|
||||
if (!$enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
|
||||
$context['count'] = (new DoctrineOrmPaginator($queryBuilder))->count();
|
||||
}
|
||||
|
||||
return \array_slice($this->pagination->getPagination($operation, $context), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\Interface\LocalizedContentInterface;
|
||||
use App\Service\LocalizationService;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Gedmo\Translatable\TranslatableListener;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait LocalizedExtensionTrait
|
||||
{
|
||||
protected LocalizationService $localizationService;
|
||||
|
||||
#[Required]
|
||||
public function setLocalizationService(LocalizationService $localizationService)
|
||||
{
|
||||
$this->localizationService = $localizationService;
|
||||
}
|
||||
|
||||
public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool
|
||||
{
|
||||
$reflectionClass = new \ReflectionClass($resourceClass);
|
||||
|
||||
return $reflectionClass->implementsInterface(LocalizedContentInterface::class);
|
||||
}
|
||||
|
||||
private function addLocalizationHints(QueryBuilder $queryBuilder, array $locales): Query
|
||||
{
|
||||
$query = $queryBuilder->getQuery();
|
||||
|
||||
$query->setHint(
|
||||
Query::HINT_CUSTOM_OUTPUT_WALKER,
|
||||
'Gedmo\\Translatable\\Query\\TreeWalker\\TranslationWalker'
|
||||
);
|
||||
|
||||
\array_reverse($locales);
|
||||
foreach ($locales as $locale) {
|
||||
$query->setHint(TranslatableListener::HINT_TRANSLATABLE_LOCALE, $locale);
|
||||
}
|
||||
|
||||
$query->setHint(TranslatableListener::HINT_FALLBACK, 1);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function getContextLanguages(array $context): array
|
||||
{
|
||||
$tags = $context['request']->headers->get('Accept-Language', '');
|
||||
|
||||
return $this->localizationService->getLanguages($tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryResultItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Adds localization hints to translatable entity queries.
|
||||
*
|
||||
* @see https://github.com/doctrine-extensions/DoctrineExtensions/blob/main/doc/translatable.md#using-orm-query-hint
|
||||
*/
|
||||
final class LocalizedItemExtension implements QueryResultItemExtensionInterface
|
||||
{
|
||||
use LocalizedExtensionTrait;
|
||||
|
||||
/**
|
||||
* Same priority as `api_platform.doctrine.orm.query_extension.pagination`.
|
||||
* This ensures other filters can have precedence over localization.
|
||||
*
|
||||
* @see https://api-platform.com/docs/core/extensions/#custom-doctrine-orm-extension
|
||||
*/
|
||||
public static function getDefaultPriority(): int
|
||||
{
|
||||
return -64;
|
||||
}
|
||||
|
||||
public function getResult(
|
||||
QueryBuilder $queryBuilder,
|
||||
?string $resourceClass = null,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): ?object {
|
||||
$query = $this->addLocalizationHints($queryBuilder, $this->getContextLanguages($context));
|
||||
|
||||
return $query->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryChecker;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait PaginationExtensionTrait
|
||||
{
|
||||
protected Pagination $pagination;
|
||||
|
||||
protected ManagerRegistry $managerRegistry;
|
||||
|
||||
#[Required]
|
||||
public function setManagerRegistry(ManagerRegistry $managerRegistry)
|
||||
{
|
||||
$this->managerRegistry = $managerRegistry;
|
||||
}
|
||||
|
||||
#[Required]
|
||||
public function setPagination(Pagination $pagination)
|
||||
{
|
||||
$this->pagination = $pagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator.
|
||||
*
|
||||
* Ported from api-platform/core source code
|
||||
*
|
||||
* @see https://github.com/api-platform/core/blob/a11c21335297b6026aa2b12fbe11a77e888481d7/src/Doctrine/Orm/Extension/PaginationExtension.php#L128
|
||||
*/
|
||||
private function shouldDoctrinePaginatorFetchJoinCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): bool {
|
||||
$fetchJoinCollection = $operation?->getPaginationFetchJoinCollection();
|
||||
|
||||
if (isset($context['operation_name']) && isset($fetchJoinCollection)) {
|
||||
return $fetchJoinCollection;
|
||||
}
|
||||
|
||||
if (isset($context['graphql_operation_name']) && isset($fetchJoinCollection)) {
|
||||
return $fetchJoinCollection;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Cannot count query which selects two FROM components, cannot make distinction"
|
||||
*
|
||||
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/WhereInWalker.php#L81
|
||||
* @see https://github.com/doctrine/doctrine2/issues/2910
|
||||
*/
|
||||
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (QueryChecker::hasJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// disable $fetchJoinCollection by default (performance)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the Doctrine ORM Paginator should use output walkers.
|
||||
*
|
||||
* Ported from api-platform/core source code
|
||||
*
|
||||
* @see https://github.com/api-platform/core/blob/a11c21335297b6026aa2b12fbe11a77e888481d7/src/Doctrine/Orm/Extension/PaginationExtension.php#L161
|
||||
*/
|
||||
private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool
|
||||
{
|
||||
$useOutputWalkers = $operation?->getPaginationUseOutputWalkers();
|
||||
|
||||
if (isset($context['operation_name']) && isset($useOutputWalkers)) {
|
||||
return $useOutputWalkers;
|
||||
}
|
||||
|
||||
if (isset($context['graphql_operation_name']) && isset($useOutputWalkers)) {
|
||||
return $useOutputWalkers;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
|
||||
*
|
||||
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L56
|
||||
*/
|
||||
if (QueryChecker::hasHavingClause($queryBuilder)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Cannot count query which selects two FROM components, cannot make distinction"
|
||||
*
|
||||
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L64
|
||||
*/
|
||||
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
|
||||
*
|
||||
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L77
|
||||
*/
|
||||
if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
|
||||
*
|
||||
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L150
|
||||
*/
|
||||
if (QueryChecker::hasMaxResults($queryBuilder) && QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Disable output walkers by default (performance)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class Charge
|
||||
* The Accounting receiving the consequent Transaction for this GatewayCharge.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\ManyToOne(cascade: ['persist'])]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Accounting $target = null;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class Checkout
|
||||
* The Accounting that will issue the Transactions of the GatewayCharges after a successful checkout.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\ManyToOne(cascade: ['persist'])]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Accounting $origin = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\Interface;
|
||||
|
||||
use Gedmo\Translatable\Translatable;
|
||||
|
||||
/**
|
||||
* Provides an advanced way to work with Translatable entities.
|
||||
*
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @see https://github.com/doctrine-extensions/DoctrineExtensions/blob/main/doc/translatable.md#translatable-behavior-extension-for-doctrine
|
||||
*/
|
||||
interface LocalizedContentInterface extends Translatable
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Set the locale of the working content for translations.
|
||||
*/
|
||||
public function setTranslatableLocale(string $locale);
|
||||
|
||||
/**
|
||||
* Obtain a list of the available locales to query translations of the entity.
|
||||
*/
|
||||
public function getLocales(): array;
|
||||
|
||||
/**
|
||||
* Add to the list of available translation locales.
|
||||
*/
|
||||
public function addLocale(string $locale): static;
|
||||
|
||||
/**
|
||||
* Remove from the list of available translation locales.
|
||||
*/
|
||||
public function removeLocale(string $locale): static;
|
||||
|
||||
/**
|
||||
* Set the list of available translation locales.
|
||||
*/
|
||||
public function setLocales(array $locales): static;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ namespace App\Entity\Project;
|
||||
|
||||
use App\Entity\Accounting\Accounting;
|
||||
use App\Entity\Interface\AccountingOwnerInterface;
|
||||
use App\Entity\Interface\LocalizedContentInterface;
|
||||
use App\Entity\Interface\UserOwnedInterface;
|
||||
use App\Entity\Matchfunding\MatchCallSubmission;
|
||||
use App\Entity\Trait\LocalizedContent;
|
||||
use App\Entity\Trait\MigratedEntity;
|
||||
use App\Entity\Trait\TimestampedCreationEntity;
|
||||
use App\Entity\Trait\TimestampedUpdationEntity;
|
||||
@@ -16,11 +18,13 @@ use AutoMapper\Attribute\MapProvider;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Mapping\Annotation as Gedmo;
|
||||
|
||||
#[MapProvider(EntityMapProvider::class)]
|
||||
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||
class Project implements UserOwnedInterface, AccountingOwnerInterface
|
||||
class Project implements UserOwnedInterface, AccountingOwnerInterface, LocalizedContentInterface
|
||||
{
|
||||
use LocalizedContent;
|
||||
use MigratedEntity;
|
||||
use TimestampedCreationEntity;
|
||||
use TimestampedUpdationEntity;
|
||||
@@ -34,6 +38,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface
|
||||
* The main title for the project.
|
||||
*/
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Gedmo\Translatable()]
|
||||
private ?string $title = null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\Trait;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Mapping\Annotation as Gedmo;
|
||||
|
||||
trait LocalizedContent
|
||||
{
|
||||
#[Gedmo\Locale]
|
||||
private ?string $locale = null;
|
||||
|
||||
#[ORM\Column()]
|
||||
private array $locales = [];
|
||||
|
||||
public function setTranslatableLocale(string $locale)
|
||||
{
|
||||
$this->locale = $locale;
|
||||
|
||||
$this->addLocale($locale);
|
||||
}
|
||||
|
||||
public function getLocales(): array
|
||||
{
|
||||
return $this->locales;
|
||||
}
|
||||
|
||||
public function addLocale(string $locale): static
|
||||
{
|
||||
$this->locales = \array_unique([...$this->locales, $locale]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeLocale(string $locale): static
|
||||
{
|
||||
$needle = $locale;
|
||||
|
||||
$this->locales = [...\array_filter($this->locales, function (string $locale) use ($needle) {
|
||||
return $locale !== $needle;
|
||||
})];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setLocales(array $locales): static
|
||||
{
|
||||
$this->locales = $locales;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ interface GatewayInterface
|
||||
public static function getName(): string;
|
||||
|
||||
/**
|
||||
* @return \App\Gateway\ChargeType[] The charge types that can be processed by this Gateway
|
||||
* @return ChargeType[] The charge types that can be processed by this Gateway
|
||||
*/
|
||||
public static function getSupportedChargeTypes(): array;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Gateway\Paypal;
|
||||
|
||||
use ApiPlatform\Api\IriConverterInterface;
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Entity\Gateway\Charge;
|
||||
use App\Entity\Gateway\Checkout;
|
||||
use App\Gateway\ChargeType;
|
||||
@@ -45,10 +45,10 @@ class PaypalGateway implements GatewayInterface
|
||||
|
||||
public function __construct(
|
||||
private PaypalService $paypal,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private IriConverterInterface $iriConverter,
|
||||
private CheckoutService $checkoutService,
|
||||
private CheckoutRepository $checkoutRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private IriConverterInterface $iriConverter,
|
||||
) {}
|
||||
|
||||
public static function getName(): string
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Gateway\Stripe;
|
||||
|
||||
use ApiPlatform\Api\IriConverterInterface;
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Entity\Gateway\Checkout;
|
||||
use App\Entity\User;
|
||||
use App\Entity\User\User;
|
||||
use App\Gateway\ChargeType;
|
||||
use App\Gateway\CheckoutStatus;
|
||||
use App\Gateway\GatewayInterface;
|
||||
@@ -136,7 +136,10 @@ class StripeGateway implements GatewayInterface
|
||||
{
|
||||
$owner = $checkout->getOrigin()->getOwner();
|
||||
if (!$owner instanceof User) {
|
||||
return '';
|
||||
throw new \Exception(\sprintf(
|
||||
"Checkout with Stripe must be performed by an User, instance of '%s' supplied",
|
||||
$owner::class
|
||||
));
|
||||
}
|
||||
|
||||
return $owner->getEmail();
|
||||
|
||||
@@ -21,7 +21,7 @@ class WalletService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return \App\Entity\WalletStatement[]
|
||||
* @return WalletStatement[]
|
||||
*/
|
||||
public function getStatements(Accounting $accounting): array
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Library\Benzina\Stream\StreamInterface;
|
||||
|
||||
class Benzina
|
||||
{
|
||||
/** @var Pump\PumpInterface[] */
|
||||
/** @var PumpInterface[] */
|
||||
private array $availablePumps = [];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -18,10 +18,12 @@ class AutoMapper implements AutoMapperInterface
|
||||
public function __construct(
|
||||
?string $cacheDirectory = null,
|
||||
iterable $mapProviders = [],
|
||||
iterable $mapTransformers = [],
|
||||
) {
|
||||
$this->innerMapper = InnerMapper::create(
|
||||
cacheDirectory: \sprintf('%s%s%s', $cacheDirectory, \DIRECTORY_SEPARATOR, self::CACHE_DIR),
|
||||
providers: $mapProviders,
|
||||
propertyTransformers: $mapTransformers,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mapping\Transformer;
|
||||
|
||||
use App\Entity\Accounting\Accounting;
|
||||
use App\Service\AccountingService;
|
||||
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface;
|
||||
|
||||
class AccountingBalanceMapTransformer implements PropertyTransformerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private AccountingService $accountingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Accounting $source
|
||||
*/
|
||||
public function transform(mixed $value, object|array $source, array $context): mixed
|
||||
{
|
||||
return $this->accountingService->calcBalance($source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mapping\Transformer;
|
||||
|
||||
use App\ApiResource\Gateway\GatewayApiResource;
|
||||
use App\Gateway\GatewayLocator;
|
||||
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface;
|
||||
|
||||
class GatewayNameMapTransformer implements PropertyTransformerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GatewayLocator $gatewayLocator,
|
||||
) {}
|
||||
|
||||
public function transform(mixed $value, object|array $source, array $context): mixed
|
||||
{
|
||||
$gateway = $this->gatewayLocator->get($value);
|
||||
|
||||
$resource = new GatewayApiResource();
|
||||
$resource->name = $gateway::getName();
|
||||
$resource->supports = $gateway::getSupportedChargeTypes();
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ This API is still in early development and is not set to have backward compatibi
|
||||
|
||||
# Authentication
|
||||
|
||||
The v4 API uses Access Tokens to authenticate requests. To use a Token, include it in the Authorization header using the [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/) strategy.
|
||||
The v4 API uses Access Tokens to authenticate requests. To use a Token, include it in the `Authorization` header using the [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/) strategy.
|
||||
|
||||
```shell
|
||||
```sh
|
||||
curl -X 'GET' \
|
||||
'https://api.goteo.org/v4/projects?page=1' \
|
||||
-H 'accept: application/json' \
|
||||
@@ -26,3 +26,15 @@ UserTokens exist under an User's scope, when you obtain a token this will only g
|
||||
To obtain a UserToken you must send a POST request to [/v4/user_tokens](/v4/user_tokens) with the User login credentials (username and password) in the payload. If the credentials are correct a UserToken will be created, the `token` property value of which you must include in future requests.
|
||||
|
||||
Users can delete UserTokens owned by them at any moment, revoking your application's access to the v4 API on their behalf. When a UserToken fails to authenticate a request you will receive a 401 Unauthorized response from the API, at which point your application must look to get a new UserToken from the User.
|
||||
|
||||
# Localization
|
||||
|
||||
The v4 API accepts localization of content. Resources such as Projects can have owner-submitted data (title, description, etc) in different languages, the extent of which is subject to the owner. Resources with localized content versions will expose a `locales` property listing the available localizations.
|
||||
|
||||
Retrieval of content in different locales is performed via standard HTTP [content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation). When the request supplies an `Accept-Language` header, the API will retrieve localized versions of the content where available, falling back to the default locale of the API instance, and finally to the first available localization of the content regardless of the request preferences or the API defaults if it cannot find suitable localizations.
|
||||
|
||||
Submission of localized content is performed over standard REST content submission with the addition of a `Content-Language` header to a POST, PUT or PATCH request. Only the first tag will be used to determine the submitted localization.
|
||||
|
||||
Removal of localized content is performed over standard REST content deletion with the addition of a `Content-Language` header to a DELETE request. A delete request for localized content will act partially and remove only the requested localizations, not the entire resource. To remove the resource you can send a non-localized request omitting the `Content-Language` header. Unlike on submission v4 will process all language tags included in the header and remove every matching localization.
|
||||
|
||||
Localizations are assumed to be one per language globally. Language tags will be parsed to match to an [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) two-letter languade code, e.g: a value of "en_US" will be converted to "en", ignoring the "US" subtag.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Intl\Languages;
|
||||
|
||||
class LocalizationService
|
||||
{
|
||||
public const ERROR_TAG_INVALID_LANG = "The tag '%s' does not match to any language.";
|
||||
|
||||
private string $defaultLanguage;
|
||||
|
||||
public function __construct(
|
||||
private string $defaultLocale,
|
||||
) {
|
||||
$this->defaultLanguage = $this->getPrimaryLanguage($defaultLocale);
|
||||
}
|
||||
|
||||
public function getDefaultLocale(): string
|
||||
{
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
public function getDefaultLanguage(): string
|
||||
{
|
||||
return $this->defaultLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $tags A string of language tags
|
||||
*
|
||||
* @return string The primary language of the first supplied tag, or the default language if empty
|
||||
*
|
||||
* @throws \Exception If the first tag specifies an invalid language
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/bcp/bcp47.txt
|
||||
*/
|
||||
public function getLanguage(string $tags): string
|
||||
{
|
||||
$tags = $this->parseLanguageTags($tags);
|
||||
|
||||
if (empty($tags[0])) {
|
||||
return $this->getDefaultLanguage();
|
||||
}
|
||||
|
||||
return $this->getPrimaryLanguage($tags[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $tags A string of language tags
|
||||
*
|
||||
* @return array The primary language of the supplied tags
|
||||
*
|
||||
* @throws \Exception If one of the supplied tags specifies an invalid language
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/bcp/bcp47.txt
|
||||
*/
|
||||
public function getLanguages(string $tags): array
|
||||
{
|
||||
$tags = $this->parseLanguageTags($tags);
|
||||
|
||||
foreach ($tags as $key => $tag) {
|
||||
$tags[$key] = $this->getPrimaryLanguage($tag);
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
private function getPrimaryLanguage(string $tag): string
|
||||
{
|
||||
$language = \Locale::getPrimaryLanguage($tag);
|
||||
if ($language === null) {
|
||||
throw new \Exception(\sprintf(self::ERROR_TAG_INVALID_LANG, $tag));
|
||||
}
|
||||
|
||||
if (!\in_array($language, Languages::getLanguageCodes())) {
|
||||
throw new \Exception(\sprintf(self::ERROR_TAG_INVALID_LANG, $tag));
|
||||
}
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
private function parseLanguageTags(string $tagsString): array
|
||||
{
|
||||
$languages = \array_map(function (string $tag) {
|
||||
$tag = \explode(';', $tag);
|
||||
|
||||
return \trim($tag[0]);
|
||||
}, \explode(',', $tagsString));
|
||||
|
||||
return $languages;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\State\Accounting;
|
||||
|
||||
use ApiPlatform\Api\IriConverterInterface;
|
||||
use ApiPlatform\Doctrine\Orm\State\CollectionProvider;
|
||||
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
@@ -10,12 +9,6 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\TraversablePaginator;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\Accounting\AccountingApiResource;
|
||||
use App\ApiResource\Project\ProjectApiResource;
|
||||
use App\ApiResource\User\UserApiResource;
|
||||
use App\Entity\Accounting\Accounting;
|
||||
use App\Entity\Project\Project;
|
||||
use App\Entity\Tipjar;
|
||||
use App\Entity\User\User;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Service\AccountingService;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -29,7 +22,6 @@ class AccountingStateProvider implements ProviderInterface
|
||||
private ProviderInterface $collectionProvider,
|
||||
private AutoMapper $autoMapper,
|
||||
private AccountingService $accountingService,
|
||||
private IriConverterInterface $iriConverter,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
|
||||
@@ -39,7 +31,7 @@ class AccountingStateProvider implements ProviderInterface
|
||||
|
||||
$resources = [];
|
||||
foreach ($accountings as $accounting) {
|
||||
$resources[] = $this->toResource($accounting);
|
||||
$resources[] = $this->autoMapper->map($accounting, AccountingApiResource::class);
|
||||
}
|
||||
|
||||
return new TraversablePaginator(
|
||||
@@ -56,30 +48,6 @@ class AccountingStateProvider implements ProviderInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->toResource($accounting);
|
||||
}
|
||||
|
||||
private function toResource(Accounting $accounting): AccountingApiResource
|
||||
{
|
||||
/** @var AccountingApiResource */
|
||||
$resource = $this->autoMapper->map($accounting, AccountingApiResource::class);
|
||||
$resource->balance = $this->accountingService->calcBalance($accounting);
|
||||
|
||||
$owner = $accounting->getOwner();
|
||||
|
||||
switch ($owner::class) {
|
||||
case User::class:
|
||||
$resourceClass = UserApiResource::class;
|
||||
break;
|
||||
case Project::class:
|
||||
$resourceClass = ProjectApiResource::class;
|
||||
break;
|
||||
case Tipjar::class:
|
||||
$resourceClass = Tipjar::class;
|
||||
}
|
||||
|
||||
$resource->owner = $this->autoMapper->map($owner, $resourceClass);
|
||||
|
||||
return $resource;
|
||||
return $this->autoMapper->map($accounting, AccountingApiResource::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Interface\LocalizedContentInterface;
|
||||
use App\Service\LocalizationService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Gedmo\Translatable\Entity\Translation;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
class EntityStateProcessor implements ProcessorInterface
|
||||
@@ -16,6 +20,8 @@ class EntityStateProcessor implements ProcessorInterface
|
||||
private ProcessorInterface $deleteProcessor,
|
||||
#[Autowire(service: PersistProcessor::class)]
|
||||
private ProcessorInterface $persistProcessor,
|
||||
private LocalizationService $localizationService,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -23,10 +29,88 @@ class EntityStateProcessor implements ProcessorInterface
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if ($data instanceof LocalizedContentInterface) {
|
||||
return $this->processLocalizedContent($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->deleteProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
private function processLocalizedContent(
|
||||
LocalizedContentInterface $data,
|
||||
Operation $operation,
|
||||
array $uriVariables = [],
|
||||
array $context = [],
|
||||
): mixed {
|
||||
$languages = $this->getContextLanguages($context);
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
if ($languages === null) {
|
||||
return $this->deleteProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
return $this->deleteLocalizedContent($data, $operation, $uriVariables, $context, $languages);
|
||||
}
|
||||
|
||||
$language = $languages ? $languages[0] : $this->localizationService->getDefaultLanguage();
|
||||
|
||||
$data->setTranslatableLocale($language);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
private function getContextLanguages(array $context): ?array
|
||||
{
|
||||
$tags = $context['request']->headers->get('Content-Language', '');
|
||||
|
||||
if (empty($tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->localizationService->getLanguages($tags);
|
||||
}
|
||||
|
||||
private function deleteLocalizedContent(
|
||||
LocalizedContentInterface $data,
|
||||
Operation $operation,
|
||||
array $uriVariables = [],
|
||||
array $context = [],
|
||||
array $languages = [],
|
||||
) {
|
||||
$translationRepository = $this->entityManager->getRepository(Translation::class);
|
||||
|
||||
if (!\array_diff($data->getLocales(), $languages)) {
|
||||
throw new \Exception('Cannot leave empty resource. Must have at least one available localization.');
|
||||
}
|
||||
|
||||
$nonLocalizedLanguages = \array_diff($languages, $data->getLocales());
|
||||
if (!empty($nonLocalizedLanguages)) {
|
||||
throw new \Exception(\sprintf(
|
||||
"Could not find requested localizations '%s' of the resource.",
|
||||
\join(', ', $nonLocalizedLanguages)
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($languages as $language) {
|
||||
$translations = $translationRepository->findBy([
|
||||
'locale' => $language,
|
||||
'objectClass' => $data::class,
|
||||
'foreignKey' => $data->getId(),
|
||||
]);
|
||||
|
||||
foreach ($translations as $translation) {
|
||||
$this->entityManager->remove($translation);
|
||||
}
|
||||
|
||||
$data->removeLocale($language);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ class ProjectStateProcessor implements ProcessorInterface
|
||||
|
||||
$project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context);
|
||||
|
||||
if ($project === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->autoMapper->map($project, $data);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-4
@@ -13,6 +13,20 @@
|
||||
"src/ApiResource/.gitignore"
|
||||
]
|
||||
},
|
||||
"api-platform/symfony": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.0",
|
||||
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/api_platform.yaml",
|
||||
"config/routes/api_platform.yaml",
|
||||
"src/ApiResource/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/annotations": {
|
||||
"version": "2.0",
|
||||
"recipe": {
|
||||
@@ -91,7 +105,7 @@
|
||||
]
|
||||
},
|
||||
"stof/doctrine-extensions-bundle": {
|
||||
"version": "1.11",
|
||||
"version": "1.13",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
@@ -231,6 +245,18 @@
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.2",
|
||||
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/uid.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "6.3",
|
||||
"recipe": {
|
||||
@@ -244,12 +270,12 @@
|
||||
]
|
||||
},
|
||||
"zenstruck/foundry": {
|
||||
"version": "1.36",
|
||||
"version": "2.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.10",
|
||||
"ref": "37c2f894cc098ab4c08874b80cccc8e2f8de7976"
|
||||
"version": "2.0",
|
||||
"ref": "56bc588a1b6d5d218319f70c64c2f0fbebe30ec7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/zenstruck_foundry.yaml"
|
||||
|
||||
@@ -29,9 +29,9 @@ class ProjectApiTest extends ApiTestCase
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains(['@id' => '/v4/projects']);
|
||||
$this->assertJsonContains(['@type' => 'hydra:Collection']);
|
||||
$this->assertJsonContains(['hydra:totalItems' => 0]);
|
||||
$this->assertJsonContains(['hydra:member' => []]);
|
||||
$this->assertJsonContains(['@type' => 'Collection']);
|
||||
$this->assertJsonContains(['totalItems' => 0]);
|
||||
$this->assertJsonContains(['member' => []]);
|
||||
|
||||
$owner = new User();
|
||||
$owner->setUsername('test_user');
|
||||
@@ -50,8 +50,8 @@ class ProjectApiTest extends ApiTestCase
|
||||
static::createClient()->request('GET', '/v4/projects');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains(['hydra:totalItems' => 1]);
|
||||
$this->assertJsonContains(['hydra:member' => [
|
||||
$this->assertJsonContains(['totalItems' => 1]);
|
||||
$this->assertJsonContains(['member' => [
|
||||
[
|
||||
'title' => 'Test Project',
|
||||
'status' => ProjectStatus::InEditing->value,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\LocalizationService;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class LocalizationServiceTest extends KernelTestCase
|
||||
{
|
||||
private const TAGS = 'es-AR, es;q=0.9, en;q=0.8, de;q=0.7';
|
||||
|
||||
private LocalizationService $localizationService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->localizationService = static::getContainer()->get(LocalizationService::class);
|
||||
}
|
||||
|
||||
public function testThrowsUnknownLanguageException()
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$this->localizationService->getLanguage('*;q=0.5');
|
||||
}
|
||||
|
||||
public function testThrowsUnknownLanguageExceptionInTagList()
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$this->localizationService->getLanguages('fr-FR, *;q=0.5');
|
||||
}
|
||||
|
||||
public function testGetsFirstLanguageFromTags()
|
||||
{
|
||||
$language = $this->localizationService->getLanguage(self::TAGS);
|
||||
|
||||
$this->assertEquals('es', $language);
|
||||
}
|
||||
|
||||
public function testReturnsDefaultLocale()
|
||||
{
|
||||
$this->assertEquals(
|
||||
$this->localizationService->getDefaultLanguage(),
|
||||
$this->localizationService->getLanguage('')
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetsLanguagesFromTags()
|
||||
{
|
||||
$languages = $this->localizationService->getLanguages(self::TAGS);
|
||||
|
||||
$this->assertIsArray($languages);
|
||||
$this->assertCount(4, $languages);
|
||||
|
||||
$this->assertEquals('es', $languages[0]);
|
||||
$this->assertEquals('es', $languages[1]);
|
||||
$this->assertEquals('en', $languages[2]);
|
||||
$this->assertEquals('de', $languages[3]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user