diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36f94bd..2ac613b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ jobs: run: composer install --prefer-dist --no-progress --no-interaction - name: Lint - run: make fmtcheck + run: vendor/bin/php-cs-fixer fix -v --using-cache=no --dry-run static-analysis: name: Static Analysis @@ -51,7 +51,7 @@ jobs: run: composer install --prefer-dist --no-progress --no-interaction - name: Run PHPStan - run: make analyse + run: PHPSTAN_DISABLE_PARALLEL=1 vendor/bin/phpstan analyse --configuration=phpstan.neon --no-progress --memory-limit=1G --debug docs: name: Docs @@ -70,7 +70,7 @@ jobs: run: composer install --no-interaction --prefer-dist - name: Generate docs - run: make docs + run: docker run --rm -v "$PWD:/data" "phpdoc/phpdoc:3" - name: Ensure docs are up to date run: git diff --exit-code -- docs diff --git a/.github/workflows/generate.yaml b/.github/workflows/generate.yaml index 7066b8c..a104948 100644 --- a/.github/workflows/generate.yaml +++ b/.github/workflows/generate.yaml @@ -44,7 +44,7 @@ jobs: working-directory: codegen - name: Format - run: make fmt + run: vendor/bin/php-cs-fixer fix -v --using-cache=no - name: Create GitHub App token id: app-token diff --git a/Makefile b/Makefile deleted file mode 100644 index f13a785..0000000 --- a/Makefile +++ /dev/null @@ -1,40 +0,0 @@ -# Make this makefile self-documented with target `help` -.PHONY: help -.DEFAULT_GOAL := help -help: ## Show help - @grep -Eh '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: vendor -vendor: install ## Backward-compatible alias for install - -.PHONY: install -install: composer.json composer.lock ## Install all project dependencies - composer install - -.PHONY: lock -lock: ## Update the lockfile - composer update --with-all-dependencies - -.PHONY: fmt -fmt: install ## Format code using php-cs-fixer - vendor/bin/php-cs-fixer fix -v --using-cache=no - -.PHONY: fmtcheck -fmtcheck: install ## Check code formatting - vendor/bin/php-cs-fixer fix -v --using-cache=no --dry-run - -.PHONY: docs -docs: install ## Generate API reference using phpDocumentor - docker run --rm -v "$(CURDIR):/data" "phpdoc/phpdoc:3" - -.PHONY: test -test: install ## Run PHPUnit test suite - composer test - -.PHONY: analyse -analyse: install ## Run static analysis (PHPStan) - PHPSTAN_DISABLE_PARALLEL=1 vendor/bin/phpstan analyse --configuration=phpstan.neon --no-progress --memory-limit=1G --debug - -.PHONY: generate -generate: ## Generate SDK from the local OpenAPI specs - cd codegen && go run ./... generate ../openapi.json ../src diff --git a/README.md b/README.md index a3f91f5..72fe5aa 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,12 @@ try { // SDK automatically uses SUMUP_API_KEY environment variable $sumup = new \SumUp\SumUp(); - $request = new \SumUp\Types\CheckoutCreateRequest([ - 'amount' => 10.00, - 'currency' => 'EUR', // or CheckoutCreateRequestCurrency::EUR - 'checkout_reference' => 'your-checkout-ref', - 'merchant_code' => 'YOUR-MERCHANT-CODE', - ]); + $request = new \SumUp\Types\CheckoutCreateRequest( + checkoutReference: 'your-checkout-ref', + amount: 10.00, + currency: 'EUR', // or CheckoutCreateRequestCurrency::EUR + merchantCode: 'YOUR-MERCHANT-CODE', + ); $checkout = $sumup->checkouts()->create($request); @@ -63,7 +63,7 @@ try { } ``` -Service methods also accept associative arrays as request payloads. For typed usage, prefer DTOs from `\SumUp\Types\...` with `new TypeName([...])`. +Service methods also accept associative arrays as request payloads. For typed usage, prefer DTOs from `\SumUp\Types\...` with named arguments, or use `TypeName::fromArray([...])` when you already have associative array data. ### Providing API Key Programmatically diff --git a/codegen/pkg/generator/generator.go b/codegen/pkg/generator/generator.go index 6d3b2e1..8359be4 100644 --- a/codegen/pkg/generator/generator.go +++ b/codegen/pkg/generator/generator.go @@ -53,6 +53,9 @@ type Generator struct { // enumNamespaces tracks where an enum is defined so we can reference it. enumNamespaces map[string]string + + // requestClassNames tracks schema classes used as OpenAPI request bodies. + requestClassNames map[string]struct{} } type enumDefinition struct { @@ -67,6 +70,7 @@ func New(cfg Config) *Generator { return &Generator{ cfg: cfg, inlineSchemaNames: make(map[*base.SchemaProxy]string), + requestClassNames: make(map[string]struct{}), } } @@ -88,6 +92,7 @@ func (g *Generator) Load(spec *v3.Document) error { usage := g.collectSchemaUsage() g.schemasByTag, g.schemaNamespaces = g.assignSchemasToTags(usage) g.operationsByTag = g.collectOperations() + g.collectRequestClassNames() g.enumsByTag, g.enumNamespaces = g.collectEnums() return nil @@ -280,32 +285,219 @@ func (g *Generator) buildPHPClass(name string, schema *base.SchemaProxy, current buf.WriteString(propCode) } - if strings.HasSuffix(name, "Request") && name != "BadRequest" { - buf.WriteString(g.buildRequestArrayConstructor(properties)) + if g.shouldGenerateConstructorForClass(name) { + buf.WriteString(g.buildRequestConstructor(properties)) } buf.WriteString("}\n") return buf.String() } -func (g *Generator) buildRequestArrayConstructor(_ []phpProperty) string { +func (g *Generator) buildRequestConstructor(properties []phpProperty) string { var buf strings.Builder + buf.WriteString(" /**\n") + buf.WriteString(" * Create request DTO.\n") + buf.WriteString(" *\n") + for _, prop := range constructorProperties(properties) { + fmt.Fprintf(&buf, " * @param %s $%s\n", g.constructorParamDocType(prop), prop.Name) + } + buf.WriteString(" */\n") + buf.WriteString(" public function __construct(") + constructorProps := constructorProperties(properties) + if len(constructorProps) > 0 { + buf.WriteString("\n") + for idx, prop := range constructorProps { + buf.WriteString(" ") + buf.WriteString(g.constructorParamType(prop)) + buf.WriteString(" $") + buf.WriteString(prop.Name) + if prop.Optional { + buf.WriteString(" = null") + } + if idx < len(constructorProps)-1 { + buf.WriteString(",") + } + buf.WriteString("\n") + } + buf.WriteString(" ") + } + buf.WriteString(") {\n") + buf.WriteString(" \\SumUp\\Hydrator::hydrate([\n") + for _, prop := range constructorProps { + fmt.Fprintf(&buf, " '%s' => $%s,\n", prop.SerializedName, prop.Name) + } + buf.WriteString(" ], self::class, $this);\n") + buf.WriteString(" }\n\n") + buf.WriteString(" /**\n") buf.WriteString(" * Create request DTO from an associative array.\n") buf.WriteString(" *\n") buf.WriteString(" * @param array $data\n") buf.WriteString(" */\n") - buf.WriteString(" public function __construct(array $data = [])\n") + buf.WriteString(" public static function fromArray(array $data): self\n") buf.WriteString(" {\n") - buf.WriteString(" if ($data !== []) {\n") - buf.WriteString(" \\SumUp\\Hydrator::hydrate($data, self::class, $this);\n") - buf.WriteString(" }\n") + requiredProps := requiredProperties(properties) + if len(requiredProps) > 0 { + buf.WriteString(" self::assertRequiredFields($data, [\n") + for _, prop := range requiredProps { + fmt.Fprintf(&buf, " '%s' => '%s',\n", prop.SerializedName, prop.Name) + } + buf.WriteString(" ]);\n\n") + } + buf.WriteString(" $request = (new \\ReflectionClass(self::class))->newInstanceWithoutConstructor();\n") + buf.WriteString(" \\SumUp\\Hydrator::hydrate($data, self::class, $request);\n\n") + buf.WriteString(" return $request;\n") buf.WriteString(" }\n\n") + if len(requiredProps) > 0 { + buf.WriteString(" /**\n") + buf.WriteString(" * @param array $data\n") + buf.WriteString(" * @param array $requiredFields\n") + buf.WriteString(" */\n") + buf.WriteString(" private static function assertRequiredFields(array $data, array $requiredFields): void\n") + buf.WriteString(" {\n") + buf.WriteString(" foreach ($requiredFields as $serializedName => $propertyName) {\n") + buf.WriteString(" if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) {\n") + buf.WriteString(" throw new \\InvalidArgumentException(sprintf('Missing required field \"%s\".', $serializedName));\n") + buf.WriteString(" }\n") + buf.WriteString(" }\n") + buf.WriteString(" }\n\n") + } + return buf.String() } +func constructorProperties(properties []phpProperty) []phpProperty { + result := make([]phpProperty, 0, len(properties)) + for _, prop := range properties { + if !prop.Optional { + result = append(result, prop) + } + } + for _, prop := range properties { + if prop.Optional { + result = append(result, prop) + } + } + return result +} + +func requiredProperties(properties []phpProperty) []phpProperty { + result := make([]phpProperty, 0, len(properties)) + for _, prop := range properties { + if !prop.Optional { + result = append(result, prop) + } + } + return result +} + +func (g *Generator) constructorParamDocType(prop phpProperty) string { + docType := prop.DocType + if docType == "" { + docType = "mixed" + } + if backingType := g.enumBackingType(prop.Type); backingType != "" { + docType += "|" + backingType + } + if prop.Optional && !strings.Contains(docType, "null") { + docType += "|null" + } + return docType +} + +func (g *Generator) constructorParamType(prop phpProperty) string { + paramType := prop.Type + if paramType == "" { + paramType = "mixed" + } + + if backingType := g.enumBackingType(paramType); backingType != "" { + paramType += "|" + backingType + } + + if prop.Optional && paramType != "mixed" { + if strings.Contains(paramType, "|") { + if !strings.Contains(paramType, "null") { + paramType += "|null" + } + } else if !strings.HasPrefix(paramType, "?") { + paramType = "?" + paramType + } + } + + return paramType +} + +func (g *Generator) enumBackingType(typeName string) string { + if typeName == "" { + return "" + } + + trimmed := strings.TrimPrefix(typeName, "\\") + parts := strings.Split(trimmed, "\\") + enumName := parts[len(parts)-1] + if enumName == "" { + return "" + } + + for _, enums := range g.enumsByTag { + for _, enum := range enums { + if enum.Name == enumName { + return enum.Type + } + } + } + + return "" +} + +func (g *Generator) collectRequestClassNames() { + g.requestClassNames = make(map[string]struct{}) + for _, operations := range g.operationsByTag { + for _, op := range operations { + if op == nil || !op.HasBody || op.BodyType == "" { + continue + } + className := phpClassBaseName(op.BodyType) + if className == "" || isBuiltinPHPType(className) { + continue + } + g.requestClassNames[className] = struct{}{} + } + } +} + +func (g *Generator) shouldGenerateConstructorForClass(className string) bool { + if className == "" || className == "BadRequest" { + return false + } + if strings.HasSuffix(className, "Request") { + return true + } + _, ok := g.requestClassNames[className] + return ok +} + +func phpClassBaseName(typeName string) string { + trimmed := strings.TrimPrefix(typeName, "\\") + if strings.Contains(trimmed, "|") { + return "" + } + parts := strings.Split(trimmed, "\\") + return parts[len(parts)-1] +} + +func isBuiltinPHPType(typeName string) bool { + switch typeName { + case "array", "mixed", "string", "int", "float", "bool", "null", "void": + return true + default: + return false + } +} + func (g *Generator) displayTagName(tagKey string) string { if tagKey == sharedTagKey { return sharedTagDisplayName diff --git a/codegen/pkg/generator/services.go b/codegen/pkg/generator/services.go index 449561b..892027c 100644 --- a/codegen/pkg/generator/services.go +++ b/codegen/pkg/generator/services.go @@ -286,10 +286,10 @@ func (g *Generator) renderServiceMethod(serviceClass string, op *operation) stri buf.WriteString(" $payload = [];\n") if op.HasBody { if op.BodyRequired { - buf.WriteString(" $payload = RequestEncoder::encode($body);\n") + buf.WriteString(g.renderBodyEncoding("$body", op.BodyType, " ")) } else { buf.WriteString(" if ($body !== null) {\n") - buf.WriteString(" $payload = RequestEncoder::encode($body);\n") + buf.WriteString(g.renderBodyEncoding("$body", op.BodyType, " ")) buf.WriteString(" }\n") } } @@ -459,7 +459,17 @@ func shouldGenerateRequestBodyClass(op *operation) bool { func buildEmptyRequestBodyClass(className string) string { var buf strings.Builder fmt.Fprintf(&buf, "/**\n * Request payload for %s.\n *\n * @package SumUp\\Services\n */\n", className) - fmt.Fprintf(&buf, "class %s\n{\n}\n", className) + fmt.Fprintf(&buf, "class %s\n{\n", className) + buf.WriteString(" /**\n") + buf.WriteString(" * Create request DTO from an associative array.\n") + buf.WriteString(" *\n") + buf.WriteString(" * @param array $data\n") + buf.WriteString(" */\n") + buf.WriteString(" public static function fromArray(array $data): self\n") + buf.WriteString(" {\n") + buf.WriteString(" return new self();\n") + buf.WriteString(" }\n") + buf.WriteString("}\n") return buf.String() } @@ -862,6 +872,36 @@ func renderBodyArgument(op *operation) string { return fmt.Sprintf("%s $body", baseType) } +func (g *Generator) renderBodyEncoding(bodyExpr string, bodyType string, indent string) string { + var buf strings.Builder + + if g.bodyTypeHasGeneratedFromArray(bodyType) { + classRef := formatClassReference(bodyType) + fmt.Fprintf(&buf, "%s$requestBody = %s;\n", indent, bodyExpr) + fmt.Fprintf(&buf, "%sif (is_array($requestBody)) {\n", indent) + fmt.Fprintf(&buf, "%s $requestBody = %s::fromArray($requestBody);\n", indent, classRef) + fmt.Fprintf(&buf, "%s}\n", indent) + fmt.Fprintf(&buf, "%s$payload = RequestEncoder::encode($requestBody);\n", indent) + return buf.String() + } + + fmt.Fprintf(&buf, "%s$payload = RequestEncoder::encode(%s);\n", indent, bodyExpr) + return buf.String() +} + +func (g *Generator) bodyTypeHasGeneratedFromArray(typeName string) bool { + if !bodyTypeAllowsArray(typeName) { + return false + } + + className := phpClassBaseName(typeName) + if className == "" { + return false + } + + return g.shouldGenerateConstructorForClass(className) +} + func bodyTypeAllowsArray(typeName string) bool { if typeName == "" { return false diff --git a/examples/checkout.php b/examples/checkout.php index ab5941d..75bf9d3 100644 --- a/examples/checkout.php +++ b/examples/checkout.php @@ -26,12 +26,12 @@ $sumup = new \SumUp\SumUp($apiKey); try { - $checkout = $sumup->checkouts()->create(new \SumUp\Types\CheckoutCreateRequest([ - 'amount' => 12.30, - 'checkout_reference' => 'TX-' . time(), - 'currency' => 'EUR', - 'merchant_code' => $merchantCode, - ])); + $checkout = $sumup->checkouts()->create(new \SumUp\Types\CheckoutCreateRequest( + checkoutReference: 'TX-' . time(), + amount: 12.30, + currency: 'EUR', + merchantCode: $merchantCode, + )); if ($checkout->id === null) { fwrite(STDERR, "Checkout was created, but response did not include an ID.\n"); diff --git a/examples/oauth2/README.md b/examples/oauth2/README.md index 370f1f1..81f298b 100644 --- a/examples/oauth2/README.md +++ b/examples/oauth2/README.md @@ -86,11 +86,12 @@ $sumup = new \SumUp\SumUp(); $sumup->setDefaultAccessToken($accessToken); // Use the SDK normally -$request = new \SumUp\Types\CheckoutCreateRequest(); -$request->amount = 10.00; -$request->currency = \SumUp\Types\CheckoutCreateRequestCurrency::EUR; -$request->checkoutReference = 'order-123'; -$request->merchantCode = $merchantCode; +$request = new \SumUp\Types\CheckoutCreateRequest( + checkoutReference: 'order-123', + amount: 10.00, + currency: \SumUp\Types\CheckoutCreateRequestCurrency::EUR, + merchantCode: $merchantCode, +); $checkout = $sumup->checkouts()->create($request); ``` diff --git a/justfile b/justfile new file mode 100644 index 0000000..4ad2647 --- /dev/null +++ b/justfile @@ -0,0 +1,43 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +# List available recipes. This is the default target. +default: help + +# Display all documented targets. +help: + @just --list + +# Backward-compatible alias for installing project dependencies. +vendor: install + +# Install all project dependencies. +install: + composer install + +# Update the Composer lockfile. +lock: + composer update --with-all-dependencies + +# Format code using php-cs-fixer. +fmt: install + vendor/bin/php-cs-fixer fix -v --using-cache=no + +# Check code formatting. +fmtcheck: install + vendor/bin/php-cs-fixer fix -v --using-cache=no --dry-run + +# Generate API reference using phpDocumentor. +docs: install + docker run --rm -v "$PWD:/data" "phpdoc/phpdoc:3" + +# Run PHPUnit test suite. +test: install + composer test + +# Run static analysis using PHPStan. +analyse: install + PHPSTAN_DISABLE_PARALLEL=1 vendor/bin/phpstan analyse --configuration=phpstan.neon --no-progress --memory-limit=1G --debug + +# Generate SDK from the local OpenAPI specs. +generate: + cd codegen && go run ./... generate ../openapi.json ../src diff --git a/src/Checkouts/Checkouts.php b/src/Checkouts/Checkouts.php index e4b9f8f..31eb568 100644 --- a/src/Checkouts/Checkouts.php +++ b/src/Checkouts/Checkouts.php @@ -28,15 +28,50 @@ class CheckoutsCreateApplePaySessionRequest */ public string $target; + /** + * Create request DTO. + * + * @param string $context + * @param string $target + */ + public function __construct( + string $context, + string $target + ) { + \SumUp\Hydrator::hydrate([ + 'context' => $context, + 'target' => $target, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + self::assertRequiredFields($data, [ + 'context' => 'context', + 'target' => 'target', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void + { + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } @@ -166,7 +201,11 @@ public function create(\SumUp\Types\CheckoutCreateRequest|array $body, ?RequestO { $path = '/v0.1/checkouts'; $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = \SumUp\Types\CheckoutCreateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -201,7 +240,11 @@ public function createApplePaySession(string $id, CheckoutsCreateApplePaySession $path = sprintf('/v0.2/checkouts/%s/apple-pay-session', rawurlencode((string) $id)); $payload = []; if ($body !== null) { - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = CheckoutsCreateApplePaySessionRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); } $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); @@ -375,7 +418,11 @@ public function process(string $id, \SumUp\Types\ProcessCheckout|array $body, ?R { $path = sprintf('/v0.1/checkouts/%s', rawurlencode((string) $id)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = \SumUp\Types\ProcessCheckout::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; diff --git a/src/Customers/Customers.php b/src/Customers/Customers.php index 6485ed0..3498f69 100644 --- a/src/Customers/Customers.php +++ b/src/Customers/Customers.php @@ -21,16 +21,30 @@ class CustomersUpdateRequest */ public ?\SumUp\Types\PersonalDetails $personalDetails = null; + /** + * Create request DTO. + * + * @param \SumUp\Types\PersonalDetails|null $personalDetails + */ + public function __construct( + ?\SumUp\Types\PersonalDetails $personalDetails = null + ) { + \SumUp\Hydrator::hydrate([ + 'personal_details' => $personalDetails, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); - } + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; } } @@ -90,7 +104,11 @@ public function create(\SumUp\Types\Customer|array $body, ?RequestOptions $reque { $path = '/v0.1/customers'; $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = \SumUp\Types\Customer::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -217,7 +235,11 @@ public function update(string $customerId, CustomersUpdateRequest|array $body, ? { $path = sprintf('/v0.1/customers/%s', rawurlencode((string) $customerId)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = CustomersUpdateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; diff --git a/src/Hydrator.php b/src/Hydrator.php index 48c261b..b30b9a3 100644 --- a/src/Hydrator.php +++ b/src/Hydrator.php @@ -41,7 +41,9 @@ public static function hydrate($payload, $className, $target = null) return $payload; } - $object = ($target instanceof $className) ? $target : new $className(); + $object = ($target instanceof $className) + ? $target + : (new ReflectionClass($className))->newInstanceWithoutConstructor(); $properties = self::getClassProperties($className); foreach ($payload as $key => $value) { diff --git a/src/Members/Members.php b/src/Members/Members.php index 050ea00..f622914 100644 --- a/src/Members/Members.php +++ b/src/Members/Members.php @@ -63,15 +63,65 @@ class MembersCreateRequest */ public ?array $attributes = null; + /** + * Create request DTO. + * + * @param string $email + * @param string[] $roles + * @param bool|null $isManagedUser + * @param string|null $password + * @param string|null $nickname + * @param array|null $metadata + * @param array|null $attributes + */ + public function __construct( + string $email, + array $roles, + ?bool $isManagedUser = null, + ?string $password = null, + ?string $nickname = null, + ?array $metadata = null, + ?array $attributes = null + ) { + \SumUp\Hydrator::hydrate([ + 'email' => $email, + 'roles' => $roles, + 'is_managed_user' => $isManagedUser, + 'password' => $password, + 'nickname' => $nickname, + 'metadata' => $metadata, + 'attributes' => $attributes, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + self::assertRequiredFields($data, [ + 'email' => 'email', + 'roles' => 'roles', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void + { + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } @@ -106,16 +156,39 @@ class MembersUpdateRequest */ public ?MembersUpdateRequestUser $user = null; + /** + * Create request DTO. + * + * @param string[]|null $roles + * @param array|null $metadata + * @param array|null $attributes + * @param MembersUpdateRequestUser|null $user + */ + public function __construct( + ?array $roles = null, + ?array $metadata = null, + ?array $attributes = null, + ?MembersUpdateRequestUser $user = null + ) { + \SumUp\Hydrator::hydrate([ + 'roles' => $roles, + 'metadata' => $metadata, + 'attributes' => $attributes, + 'user' => $user, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); - } + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; } } @@ -267,7 +340,11 @@ public function create(string $merchantCode, MembersCreateRequest|array $body, ? { $path = sprintf('/v0.1/merchants/%s/members', rawurlencode((string) $merchantCode)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = MembersCreateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -417,7 +494,11 @@ public function update(string $merchantCode, string $memberId, MembersUpdateRequ { $path = sprintf('/v0.1/merchants/%s/members/%s', rawurlencode((string) $merchantCode), rawurlencode((string) $memberId)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = MembersUpdateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; diff --git a/src/Readers/Readers.php b/src/Readers/Readers.php index f6d49c6..9d03c56 100644 --- a/src/Readers/Readers.php +++ b/src/Readers/Readers.php @@ -35,15 +35,53 @@ class ReadersCreateRequest */ public ?array $metadata = null; + /** + * Create request DTO. + * + * @param string $pairingCode + * @param string $name + * @param array|null $metadata + */ + public function __construct( + string $pairingCode, + string $name, + ?array $metadata = null + ) { + \SumUp\Hydrator::hydrate([ + 'pairing_code' => $pairingCode, + 'name' => $name, + 'metadata' => $metadata, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + self::assertRequiredFields($data, [ + 'pairing_code' => 'pairingCode', + 'name' => 'name', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void + { + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } @@ -56,6 +94,15 @@ public function __construct(array $data = []) */ class ReadersTerminateCheckoutRequest { + /** + * Create request DTO from an associative array. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(); + } } class ReadersUpdateRequest @@ -74,16 +121,33 @@ class ReadersUpdateRequest */ public ?array $metadata = null; + /** + * Create request DTO. + * + * @param string|null $name + * @param array|null $metadata + */ + public function __construct( + ?string $name = null, + ?array $metadata = null + ) { + \SumUp\Hydrator::hydrate([ + 'name' => $name, + 'metadata' => $metadata, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); - } + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; } } @@ -150,7 +214,11 @@ public function create(string $merchantCode, ReadersCreateRequest|array $body, ? { $path = sprintf('/v0.1/merchants/%s/readers', rawurlencode((string) $merchantCode)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = ReadersCreateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -184,7 +252,11 @@ public function createCheckout(string $merchantCode, string $readerId, \SumUp\Ty { $path = sprintf('/v0.1/merchants/%s/readers/%s/checkout', rawurlencode((string) $merchantCode), rawurlencode((string) $readerId)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = \SumUp\Types\CreateReaderCheckoutRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -335,7 +407,11 @@ public function terminateCheckout(string $merchantCode, string $readerId, Reader $path = sprintf('/v0.1/merchants/%s/readers/%s/terminate', rawurlencode((string) $merchantCode), rawurlencode((string) $readerId)); $payload = []; if ($body !== null) { - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = ReadersTerminateCheckoutRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); } $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); @@ -371,7 +447,11 @@ public function update(string $merchantCode, string $id, ReadersUpdateRequest|ar { $path = sprintf('/v0.1/merchants/%s/readers/%s', rawurlencode((string) $merchantCode), rawurlencode((string) $id)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = ReadersUpdateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; diff --git a/src/Roles/Roles.php b/src/Roles/Roles.php index 7bd03f4..7487424 100644 --- a/src/Roles/Roles.php +++ b/src/Roles/Roles.php @@ -42,15 +42,56 @@ class RolesCreateRequest */ public ?string $description = null; + /** + * Create request DTO. + * + * @param string $name + * @param string[] $permissions + * @param array|null $metadata + * @param string|null $description + */ + public function __construct( + string $name, + array $permissions, + ?array $metadata = null, + ?string $description = null + ) { + \SumUp\Hydrator::hydrate([ + 'name' => $name, + 'permissions' => $permissions, + 'metadata' => $metadata, + 'description' => $description, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self + { + self::assertRequiredFields($data, [ + 'name' => 'name', + 'permissions' => 'permissions', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } @@ -79,16 +120,36 @@ class RolesUpdateRequest */ public ?string $description = null; + /** + * Create request DTO. + * + * @param string|null $name + * @param string[]|null $permissions + * @param string|null $description + */ + public function __construct( + ?string $name = null, + ?array $permissions = null, + ?string $description = null + ) { + \SumUp\Hydrator::hydrate([ + 'name' => $name, + 'permissions' => $permissions, + 'description' => $description, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); - } + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; } } @@ -155,7 +216,11 @@ public function create(string $merchantCode, RolesCreateRequest|array $body, ?Re { $path = sprintf('/v0.1/merchants/%s/roles', rawurlencode((string) $merchantCode)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = RolesCreateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -274,7 +339,11 @@ public function update(string $merchantCode, string $roleId, RolesUpdateRequest| { $path = sprintf('/v0.1/merchants/%s/roles/%s', rawurlencode((string) $merchantCode), rawurlencode((string) $roleId)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = RolesUpdateRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; diff --git a/src/Subaccounts/Subaccounts.php b/src/Subaccounts/Subaccounts.php index 4cfa8c4..2d6ffca 100644 --- a/src/Subaccounts/Subaccounts.php +++ b/src/Subaccounts/Subaccounts.php @@ -38,15 +38,56 @@ class SubaccountsCreateSubAccountRequest */ public ?SubaccountsCreateSubAccountRequestPermissions $permissions = null; + /** + * Create request DTO. + * + * @param string $username + * @param string $password + * @param string|null $nickname + * @param SubaccountsCreateSubAccountRequestPermissions|null $permissions + */ + public function __construct( + string $username, + string $password, + ?string $nickname = null, + ?SubaccountsCreateSubAccountRequestPermissions $permissions = null + ) { + \SumUp\Hydrator::hydrate([ + 'username' => $username, + 'password' => $password, + 'nickname' => $nickname, + 'permissions' => $permissions, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + self::assertRequiredFields($data, [ + 'username' => 'username', + 'password' => 'password', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void + { + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } @@ -84,16 +125,42 @@ class SubaccountsUpdateSubAccountRequest */ public ?SubaccountsUpdateSubAccountRequestPermissions $permissions = null; + /** + * Create request DTO. + * + * @param string|null $password + * @param string|null $username + * @param bool|null $disabled + * @param string|null $nickname + * @param SubaccountsUpdateSubAccountRequestPermissions|null $permissions + */ + public function __construct( + ?string $password = null, + ?string $username = null, + ?bool $disabled = null, + ?string $nickname = null, + ?SubaccountsUpdateSubAccountRequestPermissions $permissions = null + ) { + \SumUp\Hydrator::hydrate([ + 'password' => $password, + 'username' => $username, + 'disabled' => $disabled, + 'nickname' => $nickname, + 'permissions' => $permissions, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); - } + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; } } @@ -261,7 +328,11 @@ public function createSubAccount(SubaccountsCreateSubAccountRequest|array $body, { $path = '/v0.1/me/accounts'; $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = SubaccountsCreateSubAccountRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; @@ -338,7 +409,11 @@ public function updateSubAccount(string $operatorId, SubaccountsUpdateSubAccount { $path = sprintf('/v0.1/me/accounts/%s', rawurlencode((string) $operatorId)); $payload = []; - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = SubaccountsUpdateSubAccountRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); $headers['Authorization'] = 'Bearer ' . $this->accessToken; diff --git a/src/Transactions/Transactions.php b/src/Transactions/Transactions.php index 82753d9..9fccc14 100644 --- a/src/Transactions/Transactions.php +++ b/src/Transactions/Transactions.php @@ -24,16 +24,30 @@ class TransactionsRefundRequest */ public ?float $amount = null; + /** + * Create request DTO. + * + * @param float|null $amount + */ + public function __construct( + ?float $amount = null + ) { + \SumUp\Hydrator::hydrate([ + 'amount' => $amount, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); - } + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; } } @@ -660,7 +674,11 @@ public function refund(string $txnId, TransactionsRefundRequest|array|null $body $path = sprintf('/v0.1/me/refund/%s', rawurlencode((string) $txnId)); $payload = []; if ($body !== null) { - $payload = RequestEncoder::encode($body); + $requestBody = $body; + if (is_array($requestBody)) { + $requestBody = TransactionsRefundRequest::fromArray($requestBody); + } + $payload = RequestEncoder::encode($requestBody); } $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()]; $headers = array_merge($headers, SdkInfo::getRuntimeHeaders()); diff --git a/src/Types/CheckoutCreateRequest.php b/src/Types/CheckoutCreateRequest.php index 4e56259..fb7abab 100644 --- a/src/Types/CheckoutCreateRequest.php +++ b/src/Types/CheckoutCreateRequest.php @@ -79,15 +79,76 @@ class CheckoutCreateRequest */ public ?string $redirectUrl = null; + /** + * Create request DTO. + * + * @param string $checkoutReference + * @param float $amount + * @param CheckoutCreateRequestCurrency|string $currency + * @param string $merchantCode + * @param string|null $description + * @param string|null $returnUrl + * @param string|null $customerId + * @param CheckoutCreateRequestPurpose|string|null $purpose + * @param string|null $validUntil + * @param string|null $redirectUrl + */ + public function __construct( + string $checkoutReference, + float $amount, + CheckoutCreateRequestCurrency|string $currency, + string $merchantCode, + ?string $description = null, + ?string $returnUrl = null, + ?string $customerId = null, + CheckoutCreateRequestPurpose|string|null $purpose = null, + ?string $validUntil = null, + ?string $redirectUrl = null + ) { + \SumUp\Hydrator::hydrate([ + 'checkout_reference' => $checkoutReference, + 'amount' => $amount, + 'currency' => $currency, + 'merchant_code' => $merchantCode, + 'description' => $description, + 'return_url' => $returnUrl, + 'customer_id' => $customerId, + 'purpose' => $purpose, + 'valid_until' => $validUntil, + 'redirect_url' => $redirectUrl, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self + { + self::assertRequiredFields($data, [ + 'checkout_reference' => 'checkoutReference', + 'amount' => 'amount', + 'currency' => 'currency', + 'merchant_code' => 'merchantCode', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } diff --git a/src/Types/CreateReaderCheckoutRequest.php b/src/Types/CreateReaderCheckoutRequest.php index 34df644..cededd1 100644 --- a/src/Types/CreateReaderCheckoutRequest.php +++ b/src/Types/CreateReaderCheckoutRequest.php @@ -87,15 +87,70 @@ class CreateReaderCheckoutRequest */ public CreateReaderCheckoutRequestTotalAmount $totalAmount; + /** + * Create request DTO. + * + * @param CreateReaderCheckoutRequestTotalAmount $totalAmount + * @param CreateReaderCheckoutRequestAade|null $aade + * @param CreateReaderCheckoutRequestAffiliate|null $affiliate + * @param CreateReaderCheckoutRequestCardType|string|null $cardType + * @param string|null $description + * @param int|null $installments + * @param string|null $returnUrl + * @param float[]|null $tipRates + * @param int|null $tipTimeout + */ + public function __construct( + CreateReaderCheckoutRequestTotalAmount $totalAmount, + ?CreateReaderCheckoutRequestAade $aade = null, + ?CreateReaderCheckoutRequestAffiliate $affiliate = null, + CreateReaderCheckoutRequestCardType|string|null $cardType = null, + ?string $description = null, + ?int $installments = null, + ?string $returnUrl = null, + ?array $tipRates = null, + ?int $tipTimeout = null + ) { + \SumUp\Hydrator::hydrate([ + 'total_amount' => $totalAmount, + 'aade' => $aade, + 'affiliate' => $affiliate, + 'card_type' => $cardType, + 'description' => $description, + 'installments' => $installments, + 'return_url' => $returnUrl, + 'tip_rates' => $tipRates, + 'tip_timeout' => $tipTimeout, + ], self::class, $this); + } + /** * Create request DTO from an associative array. * * @param array $data */ - public function __construct(array $data = []) + public static function fromArray(array $data): self + { + self::assertRequiredFields($data, [ + 'total_amount' => 'totalAmount', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void { - if ($data !== []) { - \SumUp\Hydrator::hydrate($data, self::class, $this); + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } } } diff --git a/src/Types/Customer.php b/src/Types/Customer.php index c5d2ec1..9c191fc 100644 --- a/src/Types/Customer.php +++ b/src/Types/Customer.php @@ -23,4 +23,50 @@ class Customer */ public ?PersonalDetails $personalDetails = null; + /** + * Create request DTO. + * + * @param string $customerId + * @param PersonalDetails|null $personalDetails + */ + public function __construct( + string $customerId, + ?PersonalDetails $personalDetails = null + ) { + \SumUp\Hydrator::hydrate([ + 'customer_id' => $customerId, + 'personal_details' => $personalDetails, + ], self::class, $this); + } + + /** + * Create request DTO from an associative array. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + self::assertRequiredFields($data, [ + 'customer_id' => 'customerId', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void + { + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } + } + } + } diff --git a/src/Types/ProcessCheckout.php b/src/Types/ProcessCheckout.php index 918b4c6..06384fc 100644 --- a/src/Types/ProcessCheckout.php +++ b/src/Types/ProcessCheckout.php @@ -72,4 +72,71 @@ class ProcessCheckout */ public ?PersonalDetails $personalDetails = null; + /** + * Create request DTO. + * + * @param ProcessCheckoutPaymentType|string $paymentType + * @param int|null $installments + * @param MandatePayload|null $mandate + * @param Card|null $card + * @param array|null $googlePay + * @param array|null $applePay + * @param string|null $token + * @param string|null $customerId + * @param PersonalDetails|null $personalDetails + */ + public function __construct( + ProcessCheckoutPaymentType|string $paymentType, + ?int $installments = null, + ?MandatePayload $mandate = null, + ?Card $card = null, + ?array $googlePay = null, + ?array $applePay = null, + ?string $token = null, + ?string $customerId = null, + ?PersonalDetails $personalDetails = null + ) { + \SumUp\Hydrator::hydrate([ + 'payment_type' => $paymentType, + 'installments' => $installments, + 'mandate' => $mandate, + 'card' => $card, + 'google_pay' => $googlePay, + 'apple_pay' => $applePay, + 'token' => $token, + 'customer_id' => $customerId, + 'personal_details' => $personalDetails, + ], self::class, $this); + } + + /** + * Create request DTO from an associative array. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + self::assertRequiredFields($data, [ + 'payment_type' => 'paymentType', + ]); + + $request = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + \SumUp\Hydrator::hydrate($data, self::class, $request); + + return $request; + } + + /** + * @param array $data + * @param array $requiredFields + */ + private static function assertRequiredFields(array $data, array $requiredFields): void + { + foreach ($requiredFields as $serializedName => $propertyName) { + if (!array_key_exists($serializedName, $data) && !array_key_exists($propertyName, $data)) { + throw new \InvalidArgumentException(sprintf('Missing required field "%s".', $serializedName)); + } + } + } + } diff --git a/tests/RequestEncoderTest.php b/tests/RequestEncoderTest.php index c70be93..c056e4c 100644 --- a/tests/RequestEncoderTest.php +++ b/tests/RequestEncoderTest.php @@ -18,11 +18,12 @@ public function testEncodeLeavesArrayPayloadUntouched() public function testEncodeConvertsDtoToSnakeCaseAndBackedEnumValues() { - $request = new CheckoutCreateRequest(); - $request->checkoutReference = 'order-123'; - $request->amount = 10.0; - $request->currency = CheckoutCreateRequestCurrency::EUR; - $request->merchantCode = 'MC123'; + $request = new CheckoutCreateRequest( + checkoutReference: 'order-123', + amount: 10.0, + currency: CheckoutCreateRequestCurrency::EUR, + merchantCode: 'MC123', + ); $encoded = RequestEncoder::encode($request); diff --git a/tests/TypesRequestConstructorTest.php b/tests/TypesRequestConstructorTest.php index d884fbc..a109271 100644 --- a/tests/TypesRequestConstructorTest.php +++ b/tests/TypesRequestConstructorTest.php @@ -9,17 +9,19 @@ use SumUp\Types\CreateReaderCheckoutRequestAade; use SumUp\Types\CreateReaderCheckoutRequestAffiliate; use SumUp\Types\CreateReaderCheckoutRequestTotalAmount; +use SumUp\Types\ProcessCheckout; +use SumUp\Types\ProcessCheckoutPaymentType; class TypesRequestConstructorTest extends TestCase { - public function testCheckoutCreateRequestSupportsArrayInputWithEnumCoercion(): void + public function testCheckoutCreateRequestSupportsNamedArgumentsWithEnumCoercion(): void { - $request = new CheckoutCreateRequest([ - 'checkout_reference' => 'ref-123', - 'amount' => 10, - 'currency' => 'EUR', - 'merchant_code' => 'MERCHANT-1', - ]); + $request = new CheckoutCreateRequest( + checkoutReference: 'ref-123', + amount: 10, + currency: 'EUR', + merchantCode: 'MERCHANT-1', + ); $this->assertSame('ref-123', $request->checkoutReference); $this->assertSame(10.0, $request->amount); @@ -27,9 +29,9 @@ public function testCheckoutCreateRequestSupportsArrayInputWithEnumCoercion(): v $this->assertSame('MERCHANT-1', $request->merchantCode); } - public function testCheckoutCreateRequestIgnoresUnknownProperty(): void + public function testCheckoutCreateRequestFromArrayIgnoresUnknownProperty(): void { - $request = new CheckoutCreateRequest([ + $request = CheckoutCreateRequest::fromArray([ 'checkout_reference' => 'ref-123', 'amount' => 10.0, 'currency' => CheckoutCreateRequestCurrency::EUR, @@ -40,18 +42,19 @@ public function testCheckoutCreateRequestIgnoresUnknownProperty(): void $this->assertSame('ref-123', $request->checkoutReference); } - public function testCheckoutCreateRequestAllowsPartialInput(): void + public function testCheckoutCreateRequestFromArrayRequiresRequiredFields(): void { - $request = new CheckoutCreateRequest([ + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing required field "amount".'); + + CheckoutCreateRequest::fromArray([ 'checkout_reference' => 'ref-123', ]); - - $this->assertSame('ref-123', $request->checkoutReference); } public function testCreateReaderCheckoutRequestHydratesInlineObjectProperties(): void { - $request = new CreateReaderCheckoutRequest([ + $request = CreateReaderCheckoutRequest::fromArray([ 'aade' => [ 'provider_id' => 'provider-123', 'signature' => 'base64-signature', @@ -85,4 +88,13 @@ public function testCreateReaderCheckoutRequestHydratesInlineObjectProperties(): $this->assertSame(2, $request->totalAmount->minorUnit); $this->assertSame(100, $request->totalAmount->value); } + + public function testRequestBodyDtoWithoutRequestSuffixSupportsNamedArgumentsAndFromArray(): void + { + $namedRequest = new ProcessCheckout(paymentType: 'card'); + $arrayRequest = ProcessCheckout::fromArray(['payment_type' => 'card']); + + $this->assertSame(ProcessCheckoutPaymentType::CARD, $namedRequest->paymentType); + $this->assertSame(ProcessCheckoutPaymentType::CARD, $arrayRequest->paymentType); + } }