Skip to content

Commit fa1f4ad

Browse files
authored
Fix clashing class names when generating Pkl from OpenAPI/JSON Schema (apple#98)
1 parent 5b4e443 commit fa1f4ad

13 files changed

+454
-76
lines changed

packages/k8s.contrib.crd/PklProject

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
2+
// Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -29,5 +29,5 @@ dependencies {
2929
}
3030

3131
package {
32-
version = "1.0.14"
32+
version = "2.0.0"
3333
}

packages/k8s.contrib.crd/PklProject.deps.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
},
1616
"package://pkg.pkl-lang.org/pkl-pantry/org.json_schema.contrib@1": {
1717
"type": "local",
18-
"uri": "projectpackage://pkg.pkl-lang.org/pkl-pantry/org.json_schema.contrib@1.1.1",
18+
"uri": "projectpackage://pkg.pkl-lang.org/pkl-pantry/org.json_schema.contrib@1.1.2",
1919
"path": "../org.json_schema.contrib"
2020
},
2121
"package://pkg.pkl-lang.org/pkl-pantry/pkl.experimental.syntax@1": {

packages/k8s.contrib.crd/internal/ModuleGenerator.pkl

+44-15
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
2+
// Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import "pkl:reflect"
2222
import "@jsonschema.contrib/internal/Type.pkl"
2323
import "@jsonschema.contrib/internal/TypesGenerator.pkl"
2424
import "@jsonschema.contrib/internal/utils.pkl"
25+
import "@jsonschema.contrib/internal/singularize.pkl"
2526
import "@jsonschema/JsonSchema.pkl"
2627
import "@jsonschema/Parser.pkl"
2728
import "@k8s/apiextensions-apiserver/pkg/apis/apiextensions/v1/CustomResourceDefinition.pkl"
@@ -54,7 +55,7 @@ local schema: CustomResourceDefinition.CustomResourceValidation|BetaCRD.CustomRe
5455
(if (crd is BetaCRD) version.schema ?? crd.spec.validation else version.schema)!!
5556

5657
/// The Schema
57-
rootSchema: JsonSchema((s) -> validCRDSchema(s)) = Parser.parse(new JsonRenderer {}.renderDocument(schema.openAPIV3Schema)) as JsonSchema
58+
rootSchema: JsonSchema(validCRDSchema(this)) = Parser.parse(new JsonRenderer {}.renderDocument(schema.openAPIV3Schema)) as JsonSchema
5859

5960
local ignoreProperties = Set("apiVersion", "kind", "metadata")
6061
local filteredRootSchema = (rootSchema) {
@@ -280,31 +281,59 @@ function isClassLike(schema: JsonSchema.Schema): Boolean =
280281
///
281282
/// Try to use the parent property's name as part of the class name in case of conflict.
282283
/// If already at the root, add a number at the end.
283-
local function determineTypeName(path: List<String>, candidateName: String, existingTypeNames: Set<Type>, index: Int): Type =
284-
if (existingTypeNames.contains(utils.pascalCase(candidateName)))
285-
if (path.isEmpty)
286-
determineTypeName(path, candidateName + index.toString(), existingTypeNames, index + 1)
284+
local function determineTypeName(
285+
path: List<String>,
286+
candidateName: String,
287+
existingTypeNames: Set<Type>,
288+
index: Int
289+
): Type =
290+
let (candidate = utils.pascalCase(candidateName))
291+
if (existingTypeNames.findOrNull((it) -> it.name == candidate) != null)
292+
if (path.isEmpty)
293+
determineTypeName(
294+
path,
295+
candidateName + index.toString(),
296+
existingTypeNames,
297+
index + 1
298+
)
299+
else
300+
let (newPath = dropLast(path))
301+
determineTypeName(
302+
newPath,
303+
getCandidateName(newPath) + candidate,
304+
existingTypeNames,
305+
index
306+
)
287307
else
288-
determineTypeName(
289-
path.dropLast(1),
290-
utils.pascalCase(path.last.capitalize()) + utils.pascalCase(candidateName),
291-
existingTypeNames,
292-
index
293-
)
308+
new { name = candidate; moduleName = module.moduleName }
309+
310+
// noinspection TypeMismatch
311+
local function getCandidateName(path: List<String>) =
312+
if (path.isEmpty)
313+
"Item"
314+
else if (path.last == "[]")
315+
path.dropLast(1).lastOrNull?.ifNonNull((it) -> utils.pascalCase(singularize.singularize(it))) ?? "Item"
316+
else
317+
utils.pascalCase(path.last)
318+
319+
local function dropLast(path: List<String>) =
320+
if (path.last == "[]")
321+
path.dropLast(2)
294322
else
295-
new { name = utils.pascalCase(candidateName); moduleName = module.moduleName }
323+
path.dropLast(1)
296324

297325
/// The schemas that should be rendered as classes.
298326
///
299327
/// Classes get rendered for any subschema that has [JsonSchema.properties] defined, and does not show up in converters
300328
local classSchemas: Type.TypeNames =
301329
utils._findMatchingSubSchemas(filteredRootSchema, List(), (elem) -> elem != filteredRootSchema && isClassLike(elem))
302-
.filter((path, _) -> !pathPrefixes(path).any((prefix) -> converters.containsKey(prefix))) // path or prefix are not explicitly in converters
330+
// path or prefix are not explicitly in converters
331+
.filter((path, _) -> !pathPrefixes(path).any((prefix) -> converters.containsKey(prefix)))
303332
.entries
304333
.fold(Map(), (accumulator: Type.TypeNames, pair) ->
305334
let (path = pair.first)
306335
let (schema = pair.second)
307-
let (typeName = determineTypeName(path, path.lastOrNull?.capitalize() ?? "Item", accumulator.values.toSet(), 0))
336+
let (typeName = determineTypeName(path, getCandidateName(path), accumulator.values.toSet(), 0))
308337
accumulator.put(schema, typeName)
309338
)
310339

packages/k8s.contrib.crd/tests/ModuleGenerator.pkl

+18-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
2+
// Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -17,9 +17,9 @@ module k8s.contrib.crd.tests.ModuleGenerator
1717

1818
amends "pkl:test"
1919

20-
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/api/core/v1/ResourceRequirements.pkl"
21-
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/api/core/v1/EnvVar.pkl"
22-
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/api/networking/v1/NetworkPolicy.pkl"
20+
import "@k8s/api/core/v1/ResourceRequirements.pkl"
21+
import "@k8s/api/core/v1/EnvVar.pkl"
22+
import "@k8s/api/networking/v1/NetworkPolicy.pkl"
2323

2424
import "../generate.pkl"
2525

@@ -28,11 +28,11 @@ local generator = (generate) {
2828
source = "dummy://test_uri"
2929
converters {
3030
["restateclusters.restate.dev"] {
31-
[List("spec", "compute", "env", "env")] = EnvVar
31+
[List("spec", "compute", "env", "[]")] = EnvVar
3232
[List("spec", "compute", "resources")] = ResourceRequirements
33-
[List("spec", "security", "networkPeers", "ingress", "ingres")] = NetworkPolicy.NetworkPolicyPeer
34-
[List("spec", "security", "networkPeers", "admin", "admin")] = NetworkPolicy.NetworkPolicyPeer
35-
[List("spec", "security", "networkPeers", "metrics", "metric")] = NetworkPolicy.NetworkPolicyPeer
33+
[List("spec", "security", "networkPeers", "ingress", "[]")] = NetworkPolicy.NetworkPolicyPeer
34+
[List("spec", "security", "networkPeers", "admin", "[]")] = NetworkPolicy.NetworkPolicyPeer
35+
[List("spec", "security", "networkPeers", "metrics", "[]")] = NetworkPolicy.NetworkPolicyPeer
3636
}
3737
}
3838
}
@@ -59,7 +59,16 @@ examples {
5959
}
6060

6161
for (filename, value in generator3.output.files!!) {
62-
["\(filename).pkl -- different version of k8s"] {
62+
["\(filename) -- different version of k8s"] {
63+
value.text
64+
}
65+
}
66+
67+
["conflicting schemas"] {
68+
for (_, value in (generate) {
69+
sourceContents = read("fixtures/crds_conflict.yaml")
70+
source = "dummy://test_uri"
71+
}.output.files!!) {
6372
value.text
6473
}
6574
}

packages/k8s.contrib.crd/tests/ModuleGenerator.pkl-expected.pcf

+79-1
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ examples {
197197

198198
"""
199199
}
200-
["RestateCluster.pkl.pkl -- different version of k8s"] {
200+
["RestateCluster.pkl -- different version of k8s"] {
201201
"""
202202
/// Auto-generated derived type for RestateClusterSpec via `CustomResource`
203203
///
@@ -294,6 +294,84 @@ examples {
294294
storageRequestBytes: Int(this >= 1.0)
295295
}
296296

297+
"""
298+
}
299+
["conflicting schemas"] {
300+
"""
301+
/// This module was generated from the CustomResourceDefinition at <dummy://test_uri>.
302+
module bar.foo.v1.FooBar
303+
304+
extends "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/K8sResource.pkl"
305+
306+
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/apimachinery/pkg/apis/meta/v1/ObjectMeta.pkl"
307+
308+
fixed apiVersion: "foo.bar/v1"
309+
310+
fixed kind: "FooBar"
311+
312+
/// Standard object's metadata.
313+
///
314+
/// More info: <https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata>.
315+
metadata: ObjectMeta?
316+
317+
foo: Foo?
318+
319+
class Foo {
320+
/// Test nested objects field collision.
321+
redObject: RedObject?
322+
323+
/// Test nested objects field collision.
324+
blueObject: BlueObject?
325+
}
326+
327+
/// Test nested objects field collision.
328+
class RedObject {
329+
/// Nested field.
330+
nestedField: String
331+
332+
/// Nested child object.
333+
nestedObject: NestedObject?
334+
335+
/// Nested list object red.
336+
nestedList: Listing<NestedList>?
337+
}
338+
339+
/// Nested child object.
340+
class NestedObject {
341+
/// Nested field.
342+
nestedRed: String?
343+
}
344+
345+
/// Red nested object test items.
346+
class NestedList {
347+
/// Red nested list field.
348+
nestedListItemField: String?
349+
}
350+
351+
/// Test nested objects field collision.
352+
class BlueObject {
353+
/// Nested field.
354+
nestedField: String
355+
356+
/// Nested child object.
357+
nestedObject: BlueObjectNestedObject?
358+
359+
/// Nested list object blue.
360+
nestedList: Listing<BlueObjectNestedList>?
361+
}
362+
363+
/// Nested child object.
364+
class BlueObjectNestedObject {
365+
/// Nested field.
366+
nestedBlue: String?
367+
}
368+
369+
/// Blue nested object test items.
370+
class BlueObjectNestedList {
371+
/// Blue nested list field.
372+
nestedListItemField: String?
373+
}
374+
297375
"""
298376
}
299377
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
apiVersion: apiextensions.k8s.io/v1
2+
kind: CustomResourceDefinition
3+
metadata:
4+
name: foo.bar.com
5+
spec:
6+
group: foo.bar
7+
names:
8+
categories: []
9+
kind: FooBar
10+
plural: foobar
11+
shortNames:
12+
- fb
13+
singular: foobar
14+
scope: Cluster
15+
versions:
16+
- additionalPrinterColumns: []
17+
name: v1
18+
schema:
19+
openAPIV3Schema:
20+
properties:
21+
foo:
22+
properties:
23+
redObject:
24+
description: Test nested objects field collision.
25+
properties:
26+
nestedField:
27+
description: Nested field.
28+
type: string
29+
nestedObject:
30+
description: Nested child object.
31+
properties:
32+
nestedRed:
33+
description: Nested field.
34+
type: string
35+
type: object
36+
nestedList:
37+
description: Nested list object red.
38+
items:
39+
description: Red nested object test items.
40+
properties:
41+
nestedListItemField:
42+
description: Red nested list field.
43+
type: string
44+
type: object
45+
nullable: true
46+
type: array
47+
required:
48+
- nestedField
49+
type: object
50+
blueObject:
51+
description: Test nested objects field collision.
52+
properties:
53+
nestedField:
54+
description: Nested field.
55+
type: string
56+
nestedObject:
57+
description: Nested child object.
58+
properties:
59+
nestedBlue:
60+
description: Nested field.
61+
type: string
62+
type: object
63+
nestedList:
64+
description: Nested list object blue.
65+
items:
66+
description: Blue nested object test items.
67+
properties:
68+
nestedListItemField:
69+
description: Blue nested list field.
70+
type: string
71+
type: object
72+
nullable: true
73+
type: array
74+
required:
75+
- nestedField
76+
type: object
77+
required:
78+
- image
79+
type: object
80+
served: true
81+
storage: true

packages/org.json_schema.contrib/PklProject

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
2+
// Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -25,5 +25,5 @@ dependencies {
2525
}
2626

2727
package {
28-
version = "1.1.1"
28+
version = "1.1.2"
2929
}

0 commit comments

Comments
 (0)