Skip to content

Commit e1a1624

Browse files
committed
feat: improve service request ergonomics
- generate DTO-or-array body signatures for referenced request models - generate endpoint request wrapper classes for inline object bodies - add RequestEncoder for DTO serialization and hydrator coverage tests
1 parent 3135ea9 commit e1a1624

File tree

11 files changed

+695
-96
lines changed

11 files changed

+695
-96
lines changed

codegen/pkg/generator/operations.go

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,21 @@ import (
1515
)
1616

1717
type operation struct {
18-
ID string
19-
Summary string
20-
Description string
21-
Method string
22-
Path string
23-
PathParams []operationParam
24-
QueryParams []operationParam
25-
HasQuery bool
26-
HasBody bool
27-
Deprecated bool
28-
Responses []*operationResponse
18+
ID string
19+
Summary string
20+
Description string
21+
Method string
22+
Path string
23+
PathParams []operationParam
24+
QueryParams []operationParam
25+
HasQuery bool
26+
HasBody bool
27+
BodyType string
28+
BodyDocType string
29+
BodySchema *base.SchemaProxy
30+
BodyRequired bool
31+
Deprecated bool
32+
Responses []*operationResponse
2933
}
3034

3135
type operationParam struct {
@@ -166,26 +170,71 @@ func (g *Generator) buildOperation(method, path string, op *v3.Operation, params
166170
}
167171

168172
hasBody := op.RequestBody != nil
173+
bodyType, bodyDocType, bodyRequired, bodySchema := g.resolveOperationBody(op)
169174
deprecated := false
170175
if op.Deprecated != nil {
171176
deprecated = *op.Deprecated
172177
}
173178

174179
return &operation{
175-
ID: operationID,
176-
Summary: strings.TrimSpace(op.Summary),
177-
Description: strings.TrimSpace(op.Description),
178-
Method: method,
179-
Path: path,
180-
PathParams: pathParams,
181-
QueryParams: queryParams,
182-
HasQuery: len(queryParams) > 0,
183-
HasBody: hasBody,
184-
Deprecated: deprecated,
185-
Responses: g.collectOperationResponses(op, operationID),
180+
ID: operationID,
181+
Summary: strings.TrimSpace(op.Summary),
182+
Description: strings.TrimSpace(op.Description),
183+
Method: method,
184+
Path: path,
185+
PathParams: pathParams,
186+
QueryParams: queryParams,
187+
HasQuery: len(queryParams) > 0,
188+
HasBody: hasBody,
189+
BodyType: bodyType,
190+
BodyDocType: bodyDocType,
191+
BodySchema: bodySchema,
192+
BodyRequired: bodyRequired,
193+
Deprecated: deprecated,
194+
Responses: g.collectOperationResponses(op, operationID),
186195
}, nil
187196
}
188197

198+
func (g *Generator) resolveOperationBody(op *v3.Operation) (string, string, bool, *base.SchemaProxy) {
199+
if op == nil || op.RequestBody == nil {
200+
return "", "", false, nil
201+
}
202+
203+
required := false
204+
if op.RequestBody.Required != nil && *op.RequestBody.Required {
205+
required = true
206+
}
207+
208+
var schema *base.SchemaProxy
209+
if op.RequestBody.Content != nil {
210+
if mediaType, ok := op.RequestBody.Content.Get("application/json"); ok && mediaType != nil {
211+
schema = mediaType.Schema
212+
}
213+
if schema == nil {
214+
for _, mediaType := range op.RequestBody.Content.FromOldest() {
215+
if mediaType != nil && mediaType.Schema != nil {
216+
schema = mediaType.Schema
217+
break
218+
}
219+
}
220+
}
221+
}
222+
223+
if schema == nil {
224+
return "array", "array", required, nil
225+
}
226+
227+
bodyType, bodyDocType := g.resolvePHPType(schema, "SumUp\\Services", "", "")
228+
if bodyType == "" {
229+
bodyType = "array"
230+
}
231+
if bodyDocType == "" {
232+
bodyDocType = "array"
233+
}
234+
235+
return bodyType, bodyDocType, required, schema
236+
}
237+
189238
func (op *operation) methodName() string {
190239
if op == nil {
191240
return ""

codegen/pkg/generator/services.go

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ func (g *Generator) buildServiceBlock(tagKey string, operations []*operation) st
1919
var buf strings.Builder
2020
buf.WriteString("namespace SumUp\\Services;\n\n")
2121
buf.WriteString("use SumUp\\HttpClient\\HttpClientInterface;\n")
22+
if serviceHasRequestBody(operations) {
23+
buf.WriteString("use SumUp\\RequestEncoder;\n")
24+
}
2225
buf.WriteString("use SumUp\\ResponseDecoder;\n")
2326
buf.WriteString("use SumUp\\SdkInfo;\n\n")
2427

@@ -35,6 +38,26 @@ func (g *Generator) buildServiceBlock(tagKey string, operations []*operation) st
3538
}
3639
}
3740

41+
seenRequestBodies := make(map[string]struct{})
42+
for _, op := range operations {
43+
if !shouldGenerateRequestBodyClass(op) {
44+
continue
45+
}
46+
47+
requestClass := requestBodyClassName(className, op)
48+
if _, ok := seenRequestBodies[requestClass]; ok {
49+
op.BodyType = requestClass
50+
op.BodyDocType = requestClass
51+
continue
52+
}
53+
seenRequestBodies[requestClass] = struct{}{}
54+
op.BodyType = requestClass
55+
op.BodyDocType = requestClass
56+
57+
buf.WriteString(g.buildPHPClass(requestClass, op.BodySchema, "SumUp\\Services"))
58+
buf.WriteString("\n")
59+
}
60+
3861
seenParams := make(map[string]struct{})
3962
for _, op := range operations {
4063
if op == nil || !op.HasQuery {
@@ -124,7 +147,7 @@ func (g *Generator) renderServiceMethod(serviceClass string, op *operation) stri
124147
}
125148

126149
if op.HasBody {
127-
buf.WriteString(" * @param array|null $body Optional request payload\n")
150+
fmt.Fprintf(&buf, " * @param %s $body %s request payload\n", renderBodyDocType(op), renderBodyDocQualifier(op))
128151
}
129152
buf.WriteString(" * @param array|null $requestOptions Optional request options (timeout, connect_timeout, retries, retry_backoff_ms)\n")
130153

@@ -146,7 +169,7 @@ func (g *Generator) renderServiceMethod(serviceClass string, op *operation) stri
146169
args = append(args, fmt.Sprintf("?%s $queryParams = null", queryParamsClassName(serviceClass, op)))
147170
}
148171
if op.HasBody {
149-
args = append(args, "?array $body = null")
172+
args = append(args, renderBodyArgument(op))
150173
}
151174
args = append(args, "?array $requestOptions = null")
152175

@@ -186,9 +209,13 @@ func (g *Generator) renderServiceMethod(serviceClass string, op *operation) stri
186209

187210
buf.WriteString(" $payload = [];\n")
188211
if op.HasBody {
189-
buf.WriteString(" if ($body !== null) {\n")
190-
buf.WriteString(" $payload = $body;\n")
191-
buf.WriteString(" }\n")
212+
if op.BodyRequired {
213+
buf.WriteString(" $payload = RequestEncoder::encode($body);\n")
214+
} else {
215+
buf.WriteString(" if ($body !== null) {\n")
216+
buf.WriteString(" $payload = RequestEncoder::encode($body);\n")
217+
buf.WriteString(" }\n")
218+
}
192219
}
193220

194221
buf.WriteString(" $headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()];\n")
@@ -323,6 +350,45 @@ func collectInlineResponseSchemas(operations []*operation) map[string]*base.Sche
323350
return result
324351
}
325352

353+
func serviceHasRequestBody(operations []*operation) bool {
354+
for _, op := range operations {
355+
if op != nil && op.HasBody {
356+
return true
357+
}
358+
}
359+
360+
return false
361+
}
362+
363+
func shouldGenerateRequestBodyClass(op *operation) bool {
364+
if op == nil || !op.HasBody || op.BodySchema == nil {
365+
return false
366+
}
367+
368+
if op.BodySchema.GetReference() != "" {
369+
return false
370+
}
371+
372+
if !schemaIsObject(op.BodySchema) {
373+
return false
374+
}
375+
376+
return !schemaIsAdditionalPropertiesOnly(op.BodySchema)
377+
}
378+
379+
func requestBodyClassName(serviceClass string, op *operation) string {
380+
methodName := op.methodName()
381+
if methodName == "" {
382+
methodName = "Operation"
383+
}
384+
385+
if serviceClass != "" {
386+
return fmt.Sprintf("%s%sRequest", serviceClass, strcase.ToCamel(methodName))
387+
}
388+
389+
return fmt.Sprintf("%sRequest", strcase.ToCamel(methodName))
390+
}
391+
326392
func collectInlineResponseSchema(rt *responseType, acc map[string]*base.SchemaProxy) {
327393
if rt == nil {
328394
return
@@ -597,3 +663,76 @@ func renderPathAssignment(op *operation) string {
597663

598664
return builder.String()
599665
}
666+
667+
func renderBodyDocQualifier(op *operation) string {
668+
if op != nil && op.BodyRequired {
669+
return "Required"
670+
}
671+
672+
return "Optional"
673+
}
674+
675+
func renderBodyDocType(op *operation) string {
676+
if op == nil {
677+
return "array|null"
678+
}
679+
680+
baseType := op.BodyDocType
681+
if baseType == "" {
682+
baseType = "array"
683+
}
684+
685+
if bodyTypeAllowsArray(op.BodyType) {
686+
baseType = baseType + "|array"
687+
}
688+
689+
if !op.BodyRequired && !strings.Contains(baseType, "null") {
690+
baseType += "|null"
691+
}
692+
693+
return baseType
694+
}
695+
696+
func renderBodyArgument(op *operation) string {
697+
if op == nil {
698+
return "?array $body = null"
699+
}
700+
701+
baseType := op.BodyType
702+
if baseType == "" || baseType == "mixed" {
703+
baseType = "array"
704+
}
705+
706+
if bodyTypeAllowsArray(baseType) {
707+
baseType = baseType + "|array"
708+
}
709+
710+
if !op.BodyRequired {
711+
if baseType == "array" {
712+
return "?array $body = null"
713+
}
714+
if !strings.Contains(baseType, "null") {
715+
baseType += "|null"
716+
}
717+
return fmt.Sprintf("%s $body = null", baseType)
718+
}
719+
720+
return fmt.Sprintf("%s $body", baseType)
721+
}
722+
723+
func bodyTypeAllowsArray(typeName string) bool {
724+
if typeName == "" {
725+
return false
726+
}
727+
typeName = strings.TrimPrefix(typeName, "?")
728+
if strings.Contains(typeName, "|") {
729+
return false
730+
}
731+
732+
switch typeName {
733+
case "array", "mixed", "string", "int", "float", "bool", "null", "void":
734+
return false
735+
default:
736+
return true
737+
}
738+
}

src/Checkouts/Checkouts.php

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace SumUp\Services;
88

99
use SumUp\HttpClient\HttpClientInterface;
10+
use SumUp\RequestEncoder;
1011
use SumUp\ResponseDecoder;
1112
use SumUp\SdkInfo;
1213

@@ -95,18 +96,16 @@ public function __construct(HttpClientInterface $client, string $accessToken)
9596
/**
9697
* Create a checkout
9798
*
98-
* @param array|null $body Optional request payload
99+
* @param \SumUp\Types\CheckoutCreateRequest|array $body Required request payload
99100
* @param array|null $requestOptions Optional request options (timeout, connect_timeout, retries, retry_backoff_ms)
100101
*
101102
* @return \SumUp\Types\Checkout
102103
*/
103-
public function create(?array $body = null, ?array $requestOptions = null): \SumUp\Types\Checkout
104+
public function create(\SumUp\Types\CheckoutCreateRequest|array $body, ?array $requestOptions = null): \SumUp\Types\Checkout
104105
{
105106
$path = '/v0.1/checkouts';
106107
$payload = [];
107-
if ($body !== null) {
108-
$payload = $body;
109-
}
108+
$payload = RequestEncoder::encode($body);
110109
$headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()];
111110
$headers = array_merge($headers, SdkInfo::getRuntimeHeaders());
112111
$headers['Authorization'] = 'Bearer ' . $this->accessToken;
@@ -253,18 +252,16 @@ public function listAvailablePaymentMethods(string $merchantCode, ?CheckoutsList
253252
* Process a checkout
254253
*
255254
* @param string $id Unique ID of the checkout resource.
256-
* @param array|null $body Optional request payload
255+
* @param \SumUp\Types\ProcessCheckout|array $body Required request payload
257256
* @param array|null $requestOptions Optional request options (timeout, connect_timeout, retries, retry_backoff_ms)
258257
*
259258
* @return \SumUp\Types\CheckoutSuccess|\SumUp\Types\CheckoutAccepted
260259
*/
261-
public function process(string $id, ?array $body = null, ?array $requestOptions = null): \SumUp\Types\CheckoutSuccess|\SumUp\Types\CheckoutAccepted
260+
public function process(string $id, \SumUp\Types\ProcessCheckout|array $body, ?array $requestOptions = null): \SumUp\Types\CheckoutSuccess|\SumUp\Types\CheckoutAccepted
262261
{
263262
$path = sprintf('/v0.1/checkouts/%s', rawurlencode((string) $id));
264263
$payload = [];
265-
if ($body !== null) {
266-
$payload = $body;
267-
}
264+
$payload = RequestEncoder::encode($body);
268265
$headers = ['Content-Type' => 'application/json', 'User-Agent' => SdkInfo::getUserAgent()];
269266
$headers = array_merge($headers, SdkInfo::getRuntimeHeaders());
270267
$headers['Authorization'] = 'Bearer ' . $this->accessToken;

0 commit comments

Comments
 (0)