Skip to content

Commit 73124c1

Browse files
committed
refactor: ensure kebab-case names for generated models in for TS clients
1 parent 3daceaa commit 73124c1

File tree

160 files changed

+573
-218
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

160 files changed

+573
-218
lines changed

api/oas_generator/ts_oas_generator/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ class HttpMethod(StrEnum):
198198
DEFAULT_TAG: Final[str] = "default"
199199
DEFAULT_API_TAG: Final[str] = "api"
200200

201+
202+
# Vendor extensions
203+
X_ALGOKIT_FIELD_RENAME: Final[str] = "x-algokit-field-rename"
204+
201205
# Backup directory prefix
202206
BACKUP_DIR_PREFIX: Final[str] = "tsgen_bak_"
203207

api/oas_generator/ts_oas_generator/generator/filters.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ def ts_camel_case(name: str) -> str:
5959
return pas[:1].lower() + pas[1:] if pas else pas
6060

6161

62+
def ts_kebab_case(name: str) -> str:
63+
parts = _split_words(name)
64+
return "-".join(p.lower() for p in parts)
65+
66+
6267
def ts_property_name(name: str) -> str:
6368
"""Return a safe TS property name, quoting if necessary."""
6469
return name if _IDENTIFIER_RE.match(name) else f"'{name}'"
@@ -108,6 +113,7 @@ def _inline_object(schema: dict[str, Any], schemas: dict[str, Any] | None) -> st
108113
parts: list[str] = []
109114

110115
for prop_name, prop_schema in properties.items():
116+
canonical_name = prop_schema.get(constants.X_ALGOKIT_FIELD_RENAME) or prop_name
111117
# Add property description as doc comment
112118
description = prop_schema.get("description")
113119
if description:
@@ -117,7 +123,7 @@ def _inline_object(schema: dict[str, Any], schemas: dict[str, Any] | None) -> st
117123
parts.append(f"\n {indented_doc}")
118124

119125
# Generate camelCase TS property names for better DX
120-
ts_name = ts_camel_case(prop_name)
126+
ts_name = ts_camel_case(canonical_name)
121127
ts_t = ts_type(prop_schema, schemas)
122128
opt = "" if prop_name in required else "?"
123129
parts.append(f"{ts_name}{opt}: {ts_t};")
@@ -313,6 +319,7 @@ def _traverse(obj: dict[str, Any]) -> None: # noqa: C901
313319
"ts_type": ts_type,
314320
"ts_pascal_case": ts_pascal_case,
315321
"ts_camel_case": ts_camel_case,
322+
"ts_kebab_case": ts_kebab_case,
316323
"ts_property_name": ts_property_name,
317324
"has_msgpack_2xx": has_msgpack_2xx,
318325
"response_content_types": response_content_types,

api/oas_generator/ts_oas_generator/generator/template_engine.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from jinja2 import Environment, FileSystemLoader, select_autoescape
1010

1111
from ts_oas_generator import constants
12-
from ts_oas_generator.generator.filters import FILTERS, ts_camel_case, ts_pascal_case, ts_type
12+
from ts_oas_generator.generator.filters import FILTERS, ts_camel_case, ts_kebab_case, ts_pascal_case, ts_type
1313
from ts_oas_generator.parser.oas_parser import OASParser
1414

1515
# Type aliases for clarity
@@ -120,7 +120,6 @@ def __init__(self, template_dir: Path | None = None) -> None:
120120
self.env = self._create_environment()
121121

122122
def _create_environment(self) -> Environment:
123-
"""Create and configure Jinja2 environment."""
124123
env = Environment(
125124
loader=FileSystemLoader(str(self.template_dir)),
126125
autoescape=select_autoescape(["html", "xml"]),
@@ -131,12 +130,10 @@ def _create_environment(self) -> Environment:
131130
return env
132131

133132
def render(self, template_name: str, context: TemplateContext) -> str:
134-
"""Render a single template."""
135133
template = self.env.get_template(template_name)
136134
return template.render(**context)
137135

138136
def render_batch(self, template_map: dict[Path, tuple[str, TemplateContext]]) -> FileMap:
139-
"""Render multiple templates."""
140137
return {path: self.render(template, context) for path, (template, context) in template_map.items()}
141138

142139

@@ -145,25 +142,25 @@ class SchemaProcessor:
145142

146143
def __init__(self, renderer: TemplateRenderer) -> None:
147144
self.renderer = renderer
145+
self._wire_to_canonical: dict[str, str] = {}
146+
self._camel_to_wire: dict[str, str] = {}
148147

149148
def generate_models(self, output_dir: Path, schemas: Schema) -> FileMap:
150-
"""Generate TypeScript model files from schemas."""
151149
models_dir = output_dir / constants.DirectoryName.SRC / constants.DirectoryName.MODELS
152150
files: FileMap = {}
153151

154152
# Generate individual model files
155153
for name, schema in schemas.items():
156154
context = self._create_model_context(name, schema, schemas)
157155
content = self.renderer.render(constants.MODEL_TEMPLATE, context)
158-
files[models_dir / f"{name.lower()}{constants.MODEL_FILE_EXTENSION}"] = content
156+
file_name = f"{ts_kebab_case(name)}{constants.MODEL_FILE_EXTENSION}"
157+
files[models_dir / file_name] = content
159158

160-
# Generate comprehensive AlgokitSignedTransaction model
161159
# TODO(utils-ts): Delete this temporary model once utils-ts is part of the monorepo
162160
files[models_dir / f"algokitsignedtransaction{constants.MODEL_FILE_EXTENSION}"] = self.renderer.render(
163161
"models/algokitsignedtransaction.ts.j2", {}
164162
)
165163

166-
# Generate barrel export
167164
files[models_dir / constants.INDEX_FILE] = self.renderer.render(
168165
constants.MODELS_INDEX_TEMPLATE,
169166
{"schemas": schemas, "include_temp_signed_txn": True},
@@ -172,7 +169,6 @@ def generate_models(self, output_dir: Path, schemas: Schema) -> FileMap:
172169
return files
173170

174171
def _create_model_context(self, name: str, schema: Schema, all_schemas: Schema) -> TemplateContext:
175-
"""Create context for model template rendering."""
176172
is_object = self._is_object_schema(schema)
177173
properties = self._extract_properties(schema) if is_object else []
178174

@@ -188,21 +184,19 @@ def _create_model_context(self, name: str, schema: Schema, all_schemas: Schema)
188184

189185
@staticmethod
190186
def _is_object_schema(schema: Schema) -> bool:
191-
"""Check if schema represents an object type."""
192187
is_type_object = schema.get(constants.SchemaKey.TYPE) == constants.TypeScriptType.OBJECT
193188
has_properties = constants.SchemaKey.PROPERTIES in schema
194189
has_composition = any(
195190
k in schema for k in [constants.SchemaKey.ALL_OF, constants.SchemaKey.ONE_OF, constants.SchemaKey.ANY_OF]
196191
)
197192
return (is_type_object or has_properties) and not has_composition
198193

199-
@staticmethod
200-
def _extract_properties(schema: Schema) -> list[dict[str, Any]]:
201-
"""Extract properties from an object schema."""
194+
def _extract_properties(self, schema: Schema) -> list[dict[str, Any]]:
202195
properties = []
203196
required_fields = set(schema.get(constants.SchemaKey.REQUIRED, []))
204197

205198
for prop_name, prop_schema in (schema.get(constants.SchemaKey.PROPERTIES) or {}).items():
199+
self._register_rename(prop_name, prop_schema)
206200
properties.append(
207201
{
208202
"name": prop_name,
@@ -213,6 +207,19 @@ def _extract_properties(schema: Schema) -> list[dict[str, Any]]:
213207

214208
return properties
215209

210+
def _register_rename(self, wire_name: str, schema: dict[str, Any]) -> None:
211+
rename_value = schema.get(constants.X_ALGOKIT_FIELD_RENAME)
212+
if not isinstance(rename_value, str) or not rename_value:
213+
return
214+
215+
# Preserve first occurrence to avoid accidental overrides from conflicting specs
216+
self._wire_to_canonical.setdefault(wire_name, rename_value)
217+
self._camel_to_wire.setdefault(ts_camel_case(rename_value), wire_name)
218+
219+
@property
220+
def rename_mappings(self) -> tuple[dict[str, str], dict[str, str]]:
221+
return self._wire_to_canonical, self._camel_to_wire
222+
216223

217224
class OperationProcessor:
218225
"""Processes OpenAPI operations and generates API services."""
@@ -609,6 +616,7 @@ def generate(
609616
files.update(self.schema_processor.generate_models(output_dir, all_schemas))
610617
files.update(self.operation_processor.generate_service(output_dir, ops_by_tag, tags, service_class))
611618
files.update(self._generate_client_files(output_dir, client_class, service_class))
619+
files.update(self._generate_rename_map(output_dir))
612620

613621
return files
614622

@@ -642,6 +650,7 @@ def _generate_runtime(
642650
core_dir / "json.ts": ("base/src/core/json.ts.j2", context),
643651
core_dir / "msgpack.ts": ("base/src/core/msgpack.ts.j2", context),
644652
core_dir / "casing.ts": ("base/src/core/casing.ts.j2", context),
653+
core_dir / "codecs.ts": ("base/src/core/codecs.ts.j2", context),
645654
# Project files
646655
src_dir / "index.ts": ("base/src/index.ts.j2", context),
647656
output_dir / "package.json": ("base/package.json.j2", context),
@@ -669,6 +678,23 @@ def _generate_client_files(self, output_dir: Path, client_class: str, service_cl
669678

670679
return self.renderer.render_batch(template_map)
671680

681+
def _generate_rename_map(self, output_dir: Path) -> FileMap:
682+
"""Render rename map supporting vendor rename extensions."""
683+
wire_to_canonical, camel_to_wire = self.schema_processor.rename_mappings
684+
685+
core_dir = output_dir / constants.DirectoryName.SRC / constants.DirectoryName.CORE
686+
return {
687+
core_dir / "rename-map.ts": (
688+
self.renderer.render(
689+
"base/src/core/rename-map.ts.j2",
690+
{
691+
"wire_to_canonical": wire_to_canonical,
692+
"camel_to_wire": camel_to_wire,
693+
},
694+
)
695+
)
696+
}
697+
672698
@staticmethod
673699
def _extract_class_names(package_name: str) -> tuple[str, str]:
674700
"""Extract client and service class names from package name."""

api/oas_generator/ts_oas_generator/templates/base/src/core/casing.ts.j2

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { CANONICAL_CAMEL_TO_WIRE, WIRE_TO_CANONICAL } from './rename-map';
2+
13
function toCamel(segmented: string): string {
4+
const override = WIRE_TO_CANONICAL[segmented];
5+
if (override) segmented = override;
26
if (!segmented) return segmented;
37
// Fast path: if no hyphen, return as-is but ensure typical camel conversion for underscores
48
if (!segmented.includes('-')) return segmented.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
@@ -9,6 +13,8 @@ function toCamel(segmented: string): string {
913
}
1014

1115
function toKebab(camel: string): string {
16+
const override = CANONICAL_CAMEL_TO_WIRE[camel];
17+
if (override) return override;
1218
if (!camel) return camel;
1319
// Convert camelCase or mixedCase to kebab-case; leave existing hyphens and numbers intact
1420
return camel
@@ -49,4 +55,3 @@ export function toKebabCaseKeysDeep<T = any>(value: T): T { // eslint-disable-li
4955
return out as unknown as T;
5056
}
5157

52-
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Temporary copy of codec helpers from utils-ts until the shared core moves into the monorepo.
2+
// These keep the generated clients self-contained and ready for future DTO <-> domain mappers.
3+
4+
export abstract class Codec<T, TEncoded = T> {
5+
public abstract defaultValue(): TEncoded;
6+
7+
protected toEncoded(value: T): TEncoded {
8+
return value as unknown as TEncoded;
9+
}
10+
11+
protected fromEncoded(value: TEncoded): T {
12+
return value as unknown as T;
13+
}
14+
15+
protected isDefaultValue(value: T): boolean {
16+
return this.toEncoded(value) === this.defaultValue();
17+
}
18+
19+
public encode(value?: T): TEncoded | undefined {
20+
return value !== undefined && !this.isDefaultValue(value) ? this.toEncoded(value) : undefined;
21+
}
22+
23+
public decode(value: TEncoded | undefined): T {
24+
return this.fromEncoded(value ?? this.defaultValue());
25+
}
26+
27+
public decodeOptional(value: TEncoded | undefined): T | undefined {
28+
if (value === undefined) {
29+
return undefined;
30+
}
31+
return this.fromEncoded(value);
32+
}
33+
}
34+
35+
export class NumberCodec extends Codec<number> {
36+
public defaultValue(): number {
37+
return 0;
38+
}
39+
}
40+
41+
export class BigIntCodec extends Codec<bigint, number | bigint> {
42+
public defaultValue(): bigint {
43+
return 0n;
44+
}
45+
46+
protected fromEncoded(value: number | bigint): bigint {
47+
return typeof value === 'bigint' ? value : BigInt(value);
48+
}
49+
}
50+
51+
export class StringCodec extends Codec<string> {
52+
public defaultValue(): string {
53+
return '';
54+
}
55+
}
56+
57+
export class BytesCodec extends Codec<Uint8Array> {
58+
public defaultValue(): Uint8Array {
59+
return new Uint8Array();
60+
}
61+
62+
protected isDefaultValue(value: Uint8Array): boolean {
63+
return value.byteLength === 0;
64+
}
65+
}
66+
67+
export class BooleanCodec extends Codec<boolean> {
68+
public defaultValue(): boolean {
69+
return false;
70+
}
71+
}
72+
73+
export class OmitEmptyObjectCodec<T extends object> extends Codec<T, T | undefined> {
74+
public defaultValue(): T | undefined {
75+
return undefined;
76+
}
77+
78+
protected isDefaultValue(value: T): boolean {
79+
return Object.values(value).filter((x) => x !== undefined).length === 0;
80+
}
81+
}
82+
83+
export const numberCodec = new NumberCodec();
84+
export const bigIntCodec = new BigIntCodec();
85+
export const stringCodec = new StringCodec();
86+
export const bytesCodec = new BytesCodec();
87+
export const booleanCodec = new BooleanCodec();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* Auto-generated rename hints derived from OpenAPI vendor extensions. */
2+
3+
export const WIRE_TO_CANONICAL: Record<string, string> = {
4+
{% for wire, canonical in wire_to_canonical|dictsort %}
5+
'{{ wire }}': '{{ canonical }}',
6+
{% endfor %}
7+
};
8+
9+
export const CANONICAL_CAMEL_TO_WIRE: Record<string, string> = {
10+
{% for camel, wire in camel_to_wire|dictsort %}
11+
'{{ camel }}': '{{ wire }}',
12+
{% endfor %}
13+
};
14+
15+
export const HAS_RENAME_HINTS = Object.keys(WIRE_TO_CANONICAL).length > 0;

api/oas_generator/ts_oas_generator/templates/models/index.ts.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Barrel file for models
22
{% for name, _ in schemas.items() %}
3-
export type { {{ name | ts_pascal_case }} } from './{{ name | lower }}';
3+
export type { {{ name | ts_pascal_case }} } from './{{ name | ts_kebab_case }}';
44
{% endfor %}
55
{% if include_temp_signed_txn %}
66
// TODO(utils-ts): Remove this export when utils-ts provides SignedTransaction types

api/oas_generator/ts_oas_generator/templates/models/model.ts.j2

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import type { {{ refTypes | join(', ') }} } from './index';
99
{% if isObject and schema.get('allOf') is not defined and schema.get('oneOf') is not defined and schema.get('anyOf') is not defined %}
1010
export interface {{ modelName }} {
1111
{% for prop_name, prop_schema in (schema.get('properties') or {}).items() %}
12+
{% set canonical_name = prop_schema.get('x-algokit-field-rename') or prop_name %}
1213
{{ prop_schema.description | ts_doc_comment }}
13-
{{ prop_name | ts_camel_case }}{{ '' if (schema.get('required') or []) | list | select('equalto', prop_name) | list | length > 0 else '?' }}: {{ prop_schema | ts_type(schemas) }};
14+
{{ canonical_name | ts_camel_case }}{{ '' if (schema.get('required') or []) | list | select('equalto', prop_name) | list | length > 0 else '?' }}: {{ prop_schema | ts_type(schemas) }};
1415
{% endfor %}
1516
{% if schema.get('additionalProperties') is sameas true %}
1617
[key: string]: any;

0 commit comments

Comments
 (0)