Merge branch 'main' into feat/matchfunding

This commit is contained in:
2025-01-21 14:17:54 +01:00
46 changed files with 2875 additions and 1149 deletions
+18 -10
View File
@@ -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
+14 -2
View File
@@ -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
+6 -3
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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],
];
+1 -3
View File
@@ -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:
+7 -1
View File
@@ -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:
+3
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
framework:
uid:
default_uuid_version: 7
time_based_uuid_version: 7
-2
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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:
+9 -3
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
[openssl]
openssl.cafile=/etc/ssl/certs/ca-certificates.crt
+1
View File
@@ -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);
}
}
+55
View File
@@ -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);
}
}
+49
View File
@@ -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 {}
}
+128
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
}
+6 -1
View File
@@ -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;
/**
+52
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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
+6 -3
View File
@@ -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();
+1 -1
View File
@@ -21,7 +21,7 @@ class WalletService
) {}
/**
* @return \App\Entity\WalletStatement[]
* @return WalletStatement[]
*/
public function getStatements(Accounting $accounting): array
{
+1 -1
View File
@@ -7,7 +7,7 @@ use App\Library\Benzina\Stream\StreamInterface;
class Benzina
{
/** @var Pump\PumpInterface[] */
/** @var PumpInterface[] */
private array $availablePumps = [];
public function __construct(
+2
View File
@@ -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;
}
}
+14 -2
View File
@@ -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.
+93
View File
@@ -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);
}
}
+84
View File
@@ -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
View File
@@ -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"
+5 -5
View File
@@ -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,
+62
View File
@@ -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]);
}
}