From 7d8973cb87ba1d97d91aad1ab676cff1f65f5ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 23 Apr 2026 14:33:36 +0200 Subject: [PATCH] feat: improve generated request DTO ergonomics Generate named-argument constructors and `fromArray` factories for OpenAPI request body DTOs. This improves SDK ergonomics by making required fields explicit in typed DTO construction while preserving associative-array input for callers that already work with array payloads. Array request bodies passed to service methods are now hydrated through the generated DTO factories before encoding, so missing required fields fail locally before an HTTP request is made. Response hydration now instantiates DTOs without invoking constructors, keeping response decoding compatible with request DTOs that have required constructor parameters. Tests and examples were updated to cover named constructors, fromArray hydration, enum coercion, and request body DTOs whose class names do not end in Request. --- .github/workflows/ci.yaml | 6 +- .github/workflows/generate.yaml | 2 +- Makefile | 40 ----- README.md | 14 +- codegen/pkg/generator/generator.go | 206 +++++++++++++++++++++- codegen/pkg/generator/services.go | 46 ++++- examples/checkout.php | 12 +- examples/oauth2/README.md | 11 +- justfile | 43 +++++ src/Checkouts/Checkouts.php | 59 ++++++- src/Customers/Customers.php | 34 +++- src/Hydrator.php | 4 +- src/Members/Members.php | 99 ++++++++++- src/Readers/Readers.php | 102 +++++++++-- src/Roles/Roles.php | 87 ++++++++- src/Subaccounts/Subaccounts.php | 93 +++++++++- src/Transactions/Transactions.php | 28 ++- src/Types/CheckoutCreateRequest.php | 67 ++++++- src/Types/CreateReaderCheckoutRequest.php | 61 ++++++- src/Types/Customer.php | 46 +++++ src/Types/ProcessCheckout.php | 67 +++++++ tests/RequestEncoderTest.php | 11 +- tests/TypesRequestConstructorTest.php | 40 +++-- 23 files changed, 1025 insertions(+), 153 deletions(-) delete mode 100644 Makefile create mode 100644 justfile 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); + } }