Skip to content

Commit e443679

Browse files
committed
feat: support enums
1 parent a2432fe commit e443679

File tree

21 files changed

+2752
-540
lines changed

21 files changed

+2752
-540
lines changed

codegen/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,58 @@ Generate the SDK using:
1313
```sh
1414
go run ./... generate ./openapi.yaml ./build
1515
```
16+
17+
## Features
18+
19+
### Enum Support
20+
21+
The codegen automatically generates PHP 8.1+ backed enums for properties with enum constraints in the OpenAPI specification. Enums are consolidated within their respective tag files alongside model classes.
22+
23+
For example, given an OpenAPI schema property:
24+
25+
```yaml
26+
status:
27+
type: string
28+
enum:
29+
- PENDING
30+
- FAILED
31+
- PAID
32+
```
33+
34+
The generator creates:
35+
1. A PHP enum (e.g., `CheckoutStatus` in `Checkouts.php`):
36+
```php
37+
enum CheckoutStatus: string
38+
{
39+
case PENDING = 'PENDING';
40+
case FAILED = 'FAILED';
41+
case PAID = 'PAID';
42+
}
43+
```
44+
45+
2. Model properties with enum types:
46+
```php
47+
public ?CheckoutStatus $status = null;
48+
```
49+
50+
#### File Organization
51+
52+
All enums for a tag are generated at the top of the tag's PHP file, followed by the model classes. This keeps related enums and models together while minimizing the number of files.
53+
54+
**Example structure of `Checkouts.php`:**
55+
```php
56+
<?php
57+
namespace SumUp\Checkouts;
58+
59+
enum CheckoutStatus: string { /* ... */ }
60+
enum CardType: string { /* ... */ }
61+
// ... other enums ...
62+
63+
class Checkout { /* ... */ }
64+
class CheckoutRequest { /* ... */ }
65+
// ... other classes ...
66+
```
67+
68+
#### Autoloading
69+
70+
Generated files with multiple classes and enums are added to the `classmap` in `composer.json` to ensure proper autoloading.

codegen/pkg/generator/generator.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ type Generator struct {
4141
schemaNamespaces map[string]string
4242

4343
operationsByTag map[string][]*operation
44+
45+
// enumsByTag maps normalized tag names to enums they own.
46+
enumsByTag map[string][]enumDefinition
47+
48+
// enumNamespaces tracks where an enum is defined so we can reference it.
49+
enumNamespaces map[string]string
50+
}
51+
52+
type enumDefinition struct {
53+
Name string
54+
Description string
55+
Values []string
56+
Type string // "string" or "int"
4457
}
4558

4659
// New creates a new Generator instance.
@@ -68,6 +81,7 @@ func (g *Generator) Load(spec *v3.Document) error {
6881
usage := g.collectSchemaUsage()
6982
g.schemasByTag, g.schemaNamespaces = g.assignSchemasToTags(usage)
7083
g.operationsByTag = g.collectOperations()
84+
g.enumsByTag, g.enumNamespaces = g.collectEnums()
7185

7286
return nil
7387
}
@@ -128,6 +142,18 @@ func (g *Generator) writeTagModels(tagKey string, schemas []*base.SchemaProxy) e
128142
buf.WriteString("<?php\n\ndeclare(strict_types=1);\n\n")
129143
fmt.Fprintf(&buf, "namespace %s;\n\n", namespace)
130144

145+
// Write enums first if any exist for this tag
146+
if enums, ok := g.enumsByTag[tagKey]; ok && len(enums) > 0 {
147+
for idx, enum := range enums {
148+
enumCode := g.buildPHPEnum(enum)
149+
buf.WriteString(enumCode)
150+
if idx < len(enums)-1 || len(schemas) > 0 {
151+
buf.WriteString("\n")
152+
}
153+
}
154+
}
155+
156+
// Write classes
131157
for idx, schema := range schemas {
132158
className := schemaClassName(schema)
133159
classCode := g.buildPHPClass(className, schema, namespace)
@@ -141,11 +167,17 @@ func (g *Generator) writeTagModels(tagKey string, schemas []*base.SchemaProxy) e
141167
return fmt.Errorf("write file %q: %w", filename, err)
142168
}
143169

170+
enumCount := 0
171+
if enums, ok := g.enumsByTag[tagKey]; ok {
172+
enumCount = len(enums)
173+
}
174+
144175
slog.Info("generated models file",
145176
slog.String("tag", tagName),
146177
slog.String("namespace", namespace),
147178
slog.String("file", filename),
148179
slog.Int("classes", len(schemas)),
180+
slog.Int("enums", enumCount),
149181
)
150182

151183
return nil
@@ -210,3 +242,137 @@ func (g *Generator) namespaceForTag(tagKey string) string {
210242
tagName := g.displayTagName(tagKey)
211243
return fmt.Sprintf("SumUp\\%s", tagName)
212244
}
245+
246+
func (g *Generator) buildPHPEnum(enum enumDefinition) string {
247+
var buf strings.Builder
248+
249+
if enum.Description != "" {
250+
buf.WriteString("/**\n")
251+
for _, line := range strings.Split(enum.Description, "\n") {
252+
line = strings.TrimSpace(line)
253+
if line == "" {
254+
buf.WriteString(" *\n")
255+
continue
256+
}
257+
buf.WriteString(" * ")
258+
buf.WriteString(line)
259+
buf.WriteString("\n")
260+
}
261+
buf.WriteString(" */\n")
262+
}
263+
264+
backingType := ""
265+
if enum.Type == "string" {
266+
backingType = ": string"
267+
} else if enum.Type == "int" {
268+
backingType = ": int"
269+
}
270+
271+
fmt.Fprintf(&buf, "enum %s%s\n{\n", enum.Name, backingType)
272+
273+
for _, value := range enum.Values {
274+
caseName := phpEnumCaseName(value)
275+
if enum.Type == "string" {
276+
fmt.Fprintf(&buf, " case %s = '%s';\n", caseName, value)
277+
} else if enum.Type == "int" {
278+
fmt.Fprintf(&buf, " case %s = %s;\n", caseName, value)
279+
} else {
280+
fmt.Fprintf(&buf, " case %s;\n", caseName)
281+
}
282+
}
283+
284+
buf.WriteString("}\n")
285+
return buf.String()
286+
}
287+
288+
func (g *Generator) collectEnums() (map[string][]enumDefinition, map[string]string) {
289+
enumsByTag := make(map[string][]enumDefinition)
290+
enumNamespaces := make(map[string]string)
291+
enumsSeen := make(map[string]struct{})
292+
293+
for tagKey, schemas := range g.schemasByTag {
294+
for _, schema := range schemas {
295+
g.collectEnumsFromSchema(schema, tagKey, enumsByTag, enumNamespaces, enumsSeen, make(map[*base.SchemaProxy]struct{}))
296+
}
297+
}
298+
299+
// Sort enums by name within each tag
300+
for tag := range enumsByTag {
301+
slices.SortFunc(enumsByTag[tag], func(a, b enumDefinition) int {
302+
return strings.Compare(a.Name, b.Name)
303+
})
304+
}
305+
306+
return enumsByTag, enumNamespaces
307+
}
308+
309+
func (g *Generator) collectEnumsFromSchema(schema *base.SchemaProxy, tagKey string, enumsByTag map[string][]enumDefinition, enumNamespaces map[string]string, enumsSeen map[string]struct{}, visited map[*base.SchemaProxy]struct{}) {
310+
if schema == nil {
311+
return
312+
}
313+
314+
if _, ok := visited[schema]; ok {
315+
return
316+
}
317+
visited[schema] = struct{}{}
318+
319+
spec := schema.Schema()
320+
if spec == nil {
321+
return
322+
}
323+
324+
// Check properties for enums
325+
if spec.Properties != nil {
326+
for propName, propSchema := range spec.Properties.FromOldest() {
327+
if propSchema == nil {
328+
continue
329+
}
330+
331+
propSpec := propSchema.Schema()
332+
if propSpec == nil {
333+
continue
334+
}
335+
336+
if len(propSpec.Enum) > 0 {
337+
enumName := phpEnumName(schemaClassName(schema), propName)
338+
if _, seen := enumsSeen[enumName]; seen {
339+
continue
340+
}
341+
enumsSeen[enumName] = struct{}{}
342+
343+
enumType := "string"
344+
values := make([]string, 0, len(propSpec.Enum))
345+
for _, val := range propSpec.Enum {
346+
if val != nil && val.Value != "" {
347+
values = append(values, val.Value)
348+
}
349+
}
350+
351+
if len(values) > 0 {
352+
enum := enumDefinition{
353+
Name: enumName,
354+
Description: propSpec.Description,
355+
Values: values,
356+
Type: enumType,
357+
}
358+
enumsByTag[tagKey] = append(enumsByTag[tagKey], enum)
359+
enumNamespaces[enumName] = g.namespaceForTag(tagKey)
360+
}
361+
}
362+
363+
// Recursively check nested schemas
364+
g.collectEnumsFromSchema(propSchema, tagKey, enumsByTag, enumNamespaces, enumsSeen, visited)
365+
}
366+
}
367+
368+
// Check allOf, anyOf, oneOf compositions
369+
for _, composite := range spec.AllOf {
370+
g.collectEnumsFromSchema(composite, tagKey, enumsByTag, enumNamespaces, enumsSeen, visited)
371+
}
372+
for _, composite := range spec.AnyOf {
373+
g.collectEnumsFromSchema(composite, tagKey, enumsByTag, enumNamespaces, enumsSeen, visited)
374+
}
375+
for _, composite := range spec.OneOf {
376+
g.collectEnumsFromSchema(composite, tagKey, enumsByTag, enumNamespaces, enumsSeen, visited)
377+
}
378+
}

codegen/pkg/generator/names.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,58 @@ func sanitizeTagName(tag string) string {
4343
}
4444
return strcase.ToCamel(tag)
4545
}
46+
47+
func phpEnumName(schemaName, propertyName string) string {
48+
propertyName = strings.TrimSpace(propertyName)
49+
propertyName = strings.ReplaceAll(propertyName, "-", "_")
50+
propertyName = strings.ReplaceAll(propertyName, ".", "_")
51+
52+
baseName := strcase.ToCamel(propertyName)
53+
return schemaName + baseName
54+
}
55+
56+
func phpEnumCaseName(value string) string {
57+
value = strings.TrimSpace(value)
58+
59+
// Handle numeric values or values that start with numbers
60+
if len(value) > 0 && value[0] >= '0' && value[0] <= '9' {
61+
value = "VALUE_" + value
62+
}
63+
64+
// Replace common separators and special characters
65+
value = strings.ReplaceAll(value, "+", "_PLUS_")
66+
value = strings.ReplaceAll(value, "-", "_")
67+
value = strings.ReplaceAll(value, ".", "_")
68+
value = strings.ReplaceAll(value, " ", "_")
69+
value = strings.ReplaceAll(value, "/", "_")
70+
value = strings.ReplaceAll(value, "(", "_")
71+
value = strings.ReplaceAll(value, ")", "_")
72+
value = strings.ReplaceAll(value, "&", "_AND_")
73+
value = strings.ReplaceAll(value, "%", "_PERCENT_")
74+
value = strings.ReplaceAll(value, "#", "_HASH_")
75+
value = strings.ReplaceAll(value, "@", "_AT_")
76+
value = strings.ReplaceAll(value, "!", "_")
77+
value = strings.ReplaceAll(value, "?", "_")
78+
value = strings.ReplaceAll(value, ":", "_")
79+
value = strings.ReplaceAll(value, ";", "_")
80+
value = strings.ReplaceAll(value, ",", "_")
81+
value = strings.ReplaceAll(value, "'", "_")
82+
value = strings.ReplaceAll(value, "\"", "_")
83+
84+
// Convert to screaming snake case
85+
value = strcase.ToScreamingSnake(value)
86+
87+
// Clean up multiple underscores
88+
for strings.Contains(value, "__") {
89+
value = strings.ReplaceAll(value, "__", "_")
90+
}
91+
92+
// Ensure it doesn't start with underscore (unless it's a special value)
93+
value = strings.TrimLeft(value, "_")
94+
95+
if value == "" {
96+
return "EMPTY"
97+
}
98+
99+
return value
100+
}

codegen/pkg/generator/properties.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func (g *Generator) schemaProperties(schema *base.SchemaProxy, currentNamespace
2424
return nil
2525
}
2626

27+
schemaName := schemaClassName(schema)
2728
props := make([]phpProperty, 0, len(propertySpecs))
2829
for _, spec := range propertySpecs {
2930
prop := phpProperty{
@@ -36,7 +37,7 @@ func (g *Generator) schemaProperties(schema *base.SchemaProxy, currentNamespace
3637
prop.Description = spec.Schema.Schema().Description
3738
}
3839

39-
prop.Type, prop.DocType = g.resolvePHPType(spec.Schema, currentNamespace)
40+
prop.Type, prop.DocType = g.resolvePHPType(spec.Schema, currentNamespace, schemaName, spec.Name)
4041
props = append(props, prop)
4142
}
4243

@@ -195,14 +196,14 @@ func (g *Generator) renderProperty(prop phpProperty) string {
195196
return b.String()
196197
}
197198

198-
func (g *Generator) resolvePHPType(schema *base.SchemaProxy, currentNamespace string) (string, string) {
199+
func (g *Generator) resolvePHPType(schema *base.SchemaProxy, currentNamespace string, parentSchemaName string, propertyName string) (string, string) {
199200
if schema == nil {
200201
return "mixed", "mixed"
201202
}
202203

203204
if ref := schema.GetReference(); ref != "" {
204205
if !schemaIsObject(schema) {
205-
return g.resolvePHPTypeFromSpec(schema.Schema(), currentNamespace)
206+
return g.resolvePHPTypeFromSpec(schema.Schema(), currentNamespace, parentSchemaName, propertyName)
206207
}
207208

208209
// Check if this is an additionalProperties-only schema - treat as array
@@ -224,14 +225,29 @@ func (g *Generator) resolvePHPType(schema *base.SchemaProxy, currentNamespace st
224225
return typeName, typeName
225226
}
226227

227-
return g.resolvePHPTypeFromSpec(schema.Schema(), currentNamespace)
228+
return g.resolvePHPTypeFromSpec(schema.Schema(), currentNamespace, parentSchemaName, propertyName)
228229
}
229230

230-
func (g *Generator) resolvePHPTypeFromSpec(spec *base.Schema, currentNamespace string) (string, string) {
231+
func (g *Generator) resolvePHPTypeFromSpec(spec *base.Schema, currentNamespace string, parentSchemaName string, propertyName string) (string, string) {
231232
if spec == nil {
232233
return "mixed", "mixed"
233234
}
234235

236+
// Check if this property has an enum
237+
if len(spec.Enum) > 0 && parentSchemaName != "" && propertyName != "" {
238+
enumName := phpEnumName(parentSchemaName, propertyName)
239+
namespace := g.enumNamespaces[enumName]
240+
if namespace != "" {
241+
typeName := enumName
242+
if namespace != currentNamespace {
243+
typeName = fmt.Sprintf("\\%s\\%s", namespace, enumName)
244+
}
245+
return typeName, typeName
246+
}
247+
// Fallback to string if enum wasn't generated
248+
return "string", "string"
249+
}
250+
235251
if len(spec.Enum) > 0 {
236252
return "string", "string"
237253
}
@@ -248,7 +264,7 @@ func (g *Generator) resolvePHPTypeFromSpec(spec *base.Schema, currentNamespace s
248264
case hasSchemaType(spec, "array"):
249265
itemDoc := "mixed"
250266
if spec.Items != nil && spec.Items.A != nil {
251-
_, itemDoc = g.resolvePHPType(spec.Items.A, currentNamespace)
267+
_, itemDoc = g.resolvePHPType(spec.Items.A, currentNamespace, "", "")
252268
}
253269
return "array", itemDoc + "[]"
254270
case hasSchemaType(spec, "object"):

0 commit comments

Comments
 (0)