├── .gitattributes ├── composer.json ├── infection.json5 └── src ├── AppRequest.php ├── ArgumentValueResolver └── ContextArgumentResolver.php ├── Controller ├── LifecycleController.php └── WebhookController.php ├── DependencyInjection ├── AppConfigurationFactory.php ├── Configuration.php └── ShopwareAppExtension.php ├── Entity ├── AbstractShop.php └── ShopRepositoryBridge.php ├── EventListener ├── BeforeRegistrationStartsListener.php └── ResponseSignerListener.php ├── Exception └── ShopURLIsNotReachableException.php ├── Resources └── config │ ├── routing │ ├── lifecycle.xml │ └── webhook.xml │ └── services.xml └── ShopwareAppBundle.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /docs export-ignore 4 | /composer.lock export-ignore 5 | /phpstan.neon.dist export-ignore 6 | /.php-cs-fixer.dist.php export-ignore 7 | /phpunit.xml export-ignore 8 | /examples export-ignore 9 | /devenv.nix export-ignore 10 | /devenv.lock export-ignore 11 | /.envrc export-ignore 12 | /ecs.php export-ignore 13 | /phpunit.xml.dist export-ignore 14 | /composer.lock export-ignore 15 | /README.md export-ignore 16 | /LICENSE export-ignore 17 | /.gitignore export-ignore -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopware/app-bundle", 3 | "description": "Symfony bundle to develop shopware apps easy", 4 | "type": "symfony-bundle", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "shopware AG" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Shopware\\AppBundle\\": "src" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Shopware\\AppBundle\\Test\\": "tests" 19 | } 20 | }, 21 | "require": { 22 | "php": ">=8.1", 23 | "symfony/psr-http-message-bridge": "2.* || ^7.0", 24 | "symfony/routing": "^6.4 || ^7.0", 25 | "shopware/app-php-sdk": ">=4.1.0", 26 | "symfony/http-client": "^6.4 || ^7.0", 27 | "nyholm/psr7": "^1.8" 28 | }, 29 | "require-dev": { 30 | "doctrine/doctrine-bundle": "^2.8", 31 | "symfony/doctrine-bridge": "^6.4 || ^7.0", 32 | "doctrine/orm": "^3.0", 33 | "async-aws/async-aws-bundle": "~1.12", 34 | "async-aws/dynamo-db": "~3.2.1", 35 | "symfony/polyfill-uuid": "~1.31.0", 36 | "friendsofphp/php-cs-fixer": "^3.16", 37 | "phpstan/phpstan": "^1.10.14", 38 | "phpunit/phpunit": "^10.1", 39 | "symfony/phpunit-bridge": "^6.2.10 || ^6.3 || ^6.4 || ^7.0", 40 | "infection/infection": "^0.26.21" 41 | }, 42 | "suggest": { 43 | "doctrine/orm": "To use Doctrine as the persistence layer", 44 | "doctrine/doctrine-bundle": "To use Doctrine as the persistence layer", 45 | "symfony/doctrine-bridge": "To use Doctrine as the persistence layer", 46 | "async-aws/async-aws-bundle": "To use DynamoDB as the persistence layer", 47 | "async-aws/dynamo-db": "To use DynamoDB as the persistence layer" 48 | }, 49 | "scripts": { 50 | "test": "phpunit", 51 | "check": [ 52 | "phpunit", 53 | "php-cs-fixer fix", 54 | "phpstan analyse" 55 | ] 56 | }, 57 | "config": { 58 | "allow-plugins": { 59 | "bamarni/composer-bin-plugin": true, 60 | "php-http/discovery": true, 61 | "infection/extension-installer": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /infection.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vendor/infection/infection/resources/schema.json", 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ], 7 | }, 8 | "logs": { 9 | "stryker": { 10 | "report": "main" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true, 15 | "MatchArmRemoval": { 16 | "ignoreSourceCodeByRegex": [ 17 | "default => throw new \\\\RuntimeException\\(sprintf\\('Unsupported type %s', \\$type\\)\\).*" 18 | ] 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/AppRequest.php: -------------------------------------------------------------------------------- 1 | true, 34 | ShopInterface::class => true, 35 | WebhookAction::class => true, 36 | ModuleAction::class => true, 37 | ActionButtonAction::class => true, 38 | TaxProviderAction::class => true, 39 | PaymentPayAction::class => true, 40 | PaymentFinalizeAction::class => true, 41 | PaymentValidateAction::class => true, 42 | PaymentCaptureAction::class => true, 43 | RefundAction::class => true, 44 | StorefrontAction::class => true, 45 | CheckoutGatewayAction::class => true, 46 | ContextGatewayAction::class => true, 47 | FilterAction::class => true, 48 | ]; 49 | 50 | private const SIGNING_REQUIRED_TYPES = [ 51 | ActionButtonAction::class => true, 52 | TaxProviderAction::class => true, 53 | PaymentPayAction::class => true, 54 | PaymentFinalizeAction::class => true, 55 | PaymentValidateAction::class => true, 56 | PaymentCaptureAction::class => true, 57 | RefundAction::class => true, 58 | CheckoutGatewayAction::class => true, 59 | ContextGatewayAction::class => true, 60 | ]; 61 | 62 | public function __construct( 63 | private readonly ContextResolver $contextResolver, 64 | private readonly ShopResolver $shopResolver, 65 | private readonly HttpMessageFactoryInterface $httpFoundationFactory 66 | ) { 67 | } 68 | 69 | public function supports(Request $request, ArgumentMetadata $argument): bool 70 | { 71 | return self::SUPPORTED_TYPES[$argument->getType()] ?? false; 72 | } 73 | 74 | /** 75 | * @return iterable 76 | * @throws \JsonException|\RuntimeException 77 | */ 78 | public function resolve(Request $request, ArgumentMetadata $argument): iterable 79 | { 80 | if(!$this->supports($request, $argument)) { 81 | return; 82 | } 83 | 84 | $psrRequest = $request->attributes->get(AppRequest::PSR_REQUEST_ATTRIBUTE); 85 | 86 | if (!$psrRequest instanceof RequestInterface) { 87 | $psrRequest = $this->httpFoundationFactory->createRequest($request); 88 | $request->attributes->set(AppRequest::PSR_REQUEST_ATTRIBUTE, $psrRequest); 89 | } 90 | 91 | /** @var class-string $type */ 92 | $type = $argument->getType(); 93 | 94 | if ($type === RequestInterface::class) { 95 | yield $psrRequest; 96 | return; 97 | } 98 | 99 | $shop = $request->attributes->get(AppRequest::SHOP_ATTRIBUTE); 100 | 101 | if (!$shop instanceof ShopInterface) { 102 | $shop = $this->shopResolver->resolveShop($psrRequest); 103 | $request->attributes->set(AppRequest::SHOP_ATTRIBUTE, $shop); 104 | } 105 | 106 | if (self::SIGNING_REQUIRED_TYPES[$type] ?? false) { 107 | $request->attributes->set(AppRequest::SIGN_RESPONSE, true); 108 | } 109 | 110 | if ($type === ShopInterface::class || in_array(ShopInterface::class, class_implements($type), true)) { 111 | yield $shop; 112 | return; 113 | } 114 | 115 | match ($type) { 116 | WebhookAction::class => yield $this->contextResolver->assembleWebhook($psrRequest, $shop), 117 | ModuleAction::class => yield $this->contextResolver->assembleModule($psrRequest, $shop), 118 | ActionButtonAction::class => yield $this->contextResolver->assembleActionButton($psrRequest, $shop), 119 | TaxProviderAction::class => yield $this->contextResolver->assembleTaxProvider($psrRequest, $shop), 120 | PaymentPayAction::class => yield $this->contextResolver->assemblePaymentPay($psrRequest, $shop), 121 | PaymentFinalizeAction::class => yield $this->contextResolver->assemblePaymentFinalize($psrRequest, $shop), 122 | PaymentValidateAction::class => yield $this->contextResolver->assemblePaymentValidate($psrRequest, $shop), 123 | PaymentCaptureAction::class => yield $this->contextResolver->assemblePaymentCapture($psrRequest, $shop), 124 | RefundAction::class => yield $this->contextResolver->assemblePaymentRefund($psrRequest, $shop), 125 | StorefrontAction::class => yield $this->contextResolver->assembleStorefrontRequest($psrRequest, $shop), 126 | CheckoutGatewayAction::class => yield $this->contextResolver->assembleCheckoutGatewayRequest($psrRequest, $shop), 127 | ContextGatewayAction::class => yield $this->contextResolver->assembleContextGatewayRequest($psrRequest, $shop), 128 | FilterAction::class => yield $this->contextResolver->assembleInAppPurchasesFilterRequest($psrRequest, $shop), 129 | default => throw new \RuntimeException(sprintf('Unsupported type %s', $type)), 130 | }; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Controller/LifecycleController.php: -------------------------------------------------------------------------------- 1 | appLifecycle->register($request); 23 | } 24 | 25 | public function registerConfirm(RequestInterface $request): ResponseInterface 26 | { 27 | return $this->appLifecycle->registerConfirm($request); 28 | } 29 | 30 | public function activate(RequestInterface $request): ResponseInterface 31 | { 32 | return $this->appLifecycle->activate($request); 33 | } 34 | 35 | public function deactivate(RequestInterface $request): ResponseInterface 36 | { 37 | return $this->appLifecycle->deactivate($request); 38 | } 39 | 40 | public function delete(RequestInterface $request): ResponseInterface 41 | { 42 | return $this->appLifecycle->delete($request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Controller/WebhookController.php: -------------------------------------------------------------------------------- 1 | eventDispatcher->dispatch($webhookAction, 'webhook.' . $webhookAction->eventName); 22 | 23 | return new Response(null, Response::HTTP_NO_CONTENT); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DependencyInjection/AppConfigurationFactory.php: -------------------------------------------------------------------------------- 1 | appName, 24 | $this->appSecret, 25 | $this->urlGenerator->generate($this->shopwareAppConfirmUrl, [], UrlGeneratorInterface::ABSOLUTE_URL) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 22 | 23 | // @phpstan-ignore-next-line 24 | $rootNode->children() 25 | ->enumNode('storage') 26 | ->values(['in-memory', 'doctrine', 'dynamodb', 'auto']) 27 | ->defaultValue('auto') 28 | ->end() 29 | ->arrayNode('doctrine') 30 | ->children() 31 | ->scalarNode('shop_class') 32 | ->defaultValue(AbstractShop::class) 33 | ->end() 34 | ->end() 35 | ->end() 36 | ->arrayNode('dynamodb') 37 | ->children() 38 | ->scalarNode('table_name') 39 | ->defaultValue('shops') 40 | ->end() 41 | ->end() 42 | ->end() 43 | ->scalarNode('confirmation_url') 44 | ->defaultValue('shopware_app_lifecycle_confirm') 45 | ->end() 46 | ->scalarNode('name') 47 | ->defaultValue('TestApp') 48 | ->end() 49 | ->scalarNode('secret') 50 | ->defaultValue('TestSecret') 51 | ->end() 52 | ->booleanNode('check_if_shop_url_is_reachable') 53 | ->defaultFalse() 54 | ->end(); 55 | 56 | return $treeBuilder; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/DependencyInjection/ShopwareAppExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration(new Configuration(), $configs); 25 | 26 | $loader = new XmlFileLoader( 27 | $container, 28 | new FileLocator(__DIR__ . '/../Resources/config') 29 | ); 30 | 31 | $loader->load('services.xml'); 32 | 33 | $storage = $config['storage']; 34 | 35 | if ($storage === 'auto') { 36 | // @infection-ignore-all 37 | $storage = match (true) { 38 | ContainerBuilder::willBeAvailable('async-aws/dynamo-db', DynamoDbClient::class, ['async-aws/async-aws-bundle']) => 'dynamodb', 39 | ContainerBuilder::willBeAvailable('doctrine/orm', DoctrineBundle::class, ['doctrine/doctrine-bundle']) => 'doctrine', 40 | default => 'in-memory', 41 | }; 42 | } 43 | 44 | if ($storage === 'dynamodb') { 45 | $service = new Definition(DynamoDBRepository::class); 46 | $service->setArgument(0, new Reference(DynamoDbClient::class)); 47 | $service->setArgument(1, $config['dynamodb']['table_name'] ?? 'shops'); 48 | $container->setDefinition(ShopRepositoryInterface::class, $service); 49 | } elseif ($storage === 'doctrine') { 50 | $container->getDefinition(ShopRepositoryInterface::class) 51 | ->replaceArgument(0, $config['doctrine']['shop_class'] ?? AbstractShop::class); 52 | } else { 53 | $container->setDefinition(ShopRepositoryInterface::class, new Definition(MockShopRepository::class)); 54 | } 55 | 56 | $container->getDefinition(AppConfigurationFactory::class) 57 | ->replaceArgument(0, $config['name']) 58 | ->replaceArgument(1, $config['secret']) 59 | ->replaceArgument(2, $config['confirmation_url']); 60 | 61 | $container->setParameter( 62 | sprintf('%s.check_if_shop_url_is_reachable', Configuration::EXTENSION_ALIAS), 63 | $config['check_if_shop_url_is_reachable'] 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Entity/AbstractShop.php: -------------------------------------------------------------------------------- 1 | shopId = $shopId; 36 | $this->shopUrl = $shopUrl; 37 | $this->shopSecret = $shopSecret; 38 | } 39 | 40 | public function getShopId(): string 41 | { 42 | return $this->shopId; 43 | } 44 | 45 | public function getShopUrl(): string 46 | { 47 | return $this->shopUrl; 48 | } 49 | 50 | public function getShopSecret(): string 51 | { 52 | return $this->shopSecret; 53 | } 54 | 55 | public function getShopClientId(): ?string 56 | { 57 | return $this->shopClientId; 58 | } 59 | 60 | public function getShopClientSecret(): ?string 61 | { 62 | return $this->shopClientSecret; 63 | } 64 | 65 | public function setShopApiCredentials(string $clientId, string $clientSecret): ShopInterface 66 | { 67 | $this->shopClientId = $clientId; 68 | $this->shopClientSecret = $clientSecret; 69 | 70 | return $this; 71 | } 72 | 73 | public function setShopActive(bool $active): ShopInterface 74 | { 75 | $this->shopActive = $active; 76 | 77 | return $this; 78 | } 79 | 80 | public function setShopUrl(string $url): ShopInterface 81 | { 82 | $this->shopUrl = $url; 83 | 84 | return $this; 85 | } 86 | 87 | public function setShopSecret(string $shopSecret): ShopInterface 88 | { 89 | $this->shopSecret = $shopSecret; 90 | 91 | return $this; 92 | } 93 | 94 | public function isShopActive(): bool 95 | { 96 | return $this->shopActive; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Entity/ShopRepositoryBridge.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class ShopRepositoryBridge implements ShopRepositoryInterface 17 | { 18 | /** 19 | * @param class-string $entityClass 20 | */ 21 | public function __construct( 22 | private readonly string $entityClass, 23 | private readonly ManagerRegistry $registry 24 | ) { 25 | if (!is_subclass_of($this->entityClass, ShopInterface::class)) { 26 | throw new \InvalidArgumentException(sprintf('The shop entity class "%s" must implement "%s"', $this->entityClass, ShopInterface::class)); 27 | } 28 | if ($this->registry->getManagerForClass($this->entityClass) === null) { 29 | throw new \InvalidArgumentException(sprintf('The shop entity class "%s" must be a doctrine managed entity', $this->entityClass)); 30 | } 31 | } 32 | 33 | public function createShopStruct(string $shopId, string $shopUrl, string $shopSecret): ShopInterface 34 | { 35 | return new $this->entityClass($shopId, $shopUrl, $shopSecret); 36 | } 37 | 38 | public function createShop(ShopInterface $shop): void 39 | { 40 | $manager = $this->getManager(); 41 | $manager->persist($shop); 42 | $manager->flush(); 43 | } 44 | 45 | public function getShopFromId(string $shopId): ?ShopInterface 46 | { 47 | return $this->registry->getRepository($this->entityClass)->find($shopId); 48 | } 49 | 50 | public function updateShop(ShopInterface $shop): void 51 | { 52 | $this->registry->getManager()->flush(); 53 | } 54 | 55 | public function deleteShop(string $shopId): void 56 | { 57 | $manager = $this->getManager(); 58 | $entity = $manager->find($this->entityClass, $shopId); 59 | if (!$entity) { 60 | throw new ShopNotFoundException($shopId); 61 | } 62 | $manager->remove($entity); 63 | $manager->flush(); 64 | } 65 | 66 | private function getManager(): ObjectManager 67 | { 68 | /** @var ObjectManager $manager */ 69 | $manager = $this->registry->getManagerForClass($this->entityClass); 70 | return $manager; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/EventListener/BeforeRegistrationStartsListener.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient; 25 | $this->checkShopURLIsReachable = $checkShopURLIsReachable; 26 | } 27 | 28 | public function __invoke(BeforeRegistrationStartsEvent $event): void 29 | { 30 | if ($this->checkShopURLIsReachable === false) { 31 | return; 32 | } 33 | 34 | $shop = $event->getShop(); 35 | 36 | try { 37 | $this->httpClient->request('HEAD', sprintf("%s/api/_info/config", $shop->getShopUrl()), [ 38 | 'timeout' => 10, 39 | 'max_redirects' => 0, 40 | ]); 41 | } catch (\Throwable $e) { 42 | if (!$e instanceof TransportExceptionInterface) { 43 | return; 44 | } 45 | 46 | throw new ShopURLIsNotReachableException($shop->getShopUrl(), $e); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/EventListener/ResponseSignerListener.php: -------------------------------------------------------------------------------- 1 | getResponse(); 21 | 22 | if (!$event->getRequest()->attributes->has(AppRequest::SIGN_RESPONSE)) { 23 | return; 24 | } 25 | 26 | /** @var ShopInterface $shop */ 27 | $shop = $event->getRequest()->attributes->get(AppRequest::SHOP_ATTRIBUTE); 28 | 29 | $content = $response->getContent(); 30 | 31 | if (!$content) { 32 | return; 33 | } 34 | 35 | $response->headers->set('shopware-app-signature', hash_hmac('sha256', $content, $shop->getShopSecret())); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/ShopURLIsNotReachableException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | \Shopware\AppBundle\Controller\LifecycleController::register 9 | 10 | 11 | 12 | \Shopware\AppBundle\Controller\LifecycleController::registerConfirm 13 | 14 | 15 | 16 | \Shopware\AppBundle\Controller\LifecycleController::activate 17 | 18 | 19 | 20 | \Shopware\AppBundle\Controller\LifecycleController::deactivate 21 | 22 | 23 | 24 | \Shopware\AppBundle\Controller\LifecycleController::delete 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Resources/config/routing/webhook.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | \Shopware\AppBundle\Controller\WebhookController::dispatch 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | %shopware_app.check_if_shop_url_is_reachable% 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/ShopwareAppBundle.php: -------------------------------------------------------------------------------- 1 |