Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:28:30 +08:00
commit 171acedaa4
220 changed files with 85967 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}

View File

@@ -0,0 +1,14 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
],
"relationships": [
{ "name": "category", "type": "ONE_TO_ONE", "target": "Category", "joinColumn": "category_id", "optional": true },
{ "name": "reviews", "type": "ONE_TO_MANY", "target": "Review", "mappedBy": "product" },
{ "name": "tags", "type": "MANY_TO_MANY", "target": "Tag", "joinTable": { "name": "product_tag", "joinColumn": "product_id", "inverseJoinColumn": "tag_id" } }
]
}

View File

@@ -0,0 +1,898 @@
#!/usr/bin/env python3
"""
Spring Boot CRUD boilerplate generator
Given an entity spec, scaffold a feature-based CRUD template aligned with the
"Spring Boot CRUD Patterns" skill (domain/application/presentation/infrastructure).
Usage:
python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
--spec entity.json --package com.example.product --output ./generated
Spec format (JSON preferred; YAML supported if PyYAML is installed and file ends with .yml/.yaml):
{
"entity": "Product",
"id": {"name": "id", "type": "Long", "generated": true},
"fields": [
{"name": "name", "type": "String"},
{"name": "price", "type": "BigDecimal"},
{"name": "inStock", "type": "Boolean"}
]
}
Notes:
- Generates a feature folder with domain/application/presentation/infrastructure subpackages
- Uses Java records for DTOs, constructor injection, @Transactional, and standard REST codes
- Keep output as a starting point; adapt to your conventions
"""
import argparse
import json
import os
import re
import sys
from textwrap import dedent
from string import Template
try:
import yaml # type: ignore
_HAS_YAML = True
except Exception:
_HAS_YAML = False
# ------------------------- Helpers -------------------------
JAVA_TYPE_IMPORTS = {
"BigDecimal": "import java.math.BigDecimal;",
"UUID": "import java.util.UUID;",
"LocalDate": "import java.time.LocalDate;",
"LocalDateTime": "import java.time.LocalDateTime;",
}
JPA_IMPORTS = dedent(
"""
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
"""
).strip()
COLLECTION_IMPORTS = "import java.util.Set;"
SPRING_IMPORTS = dedent(
"""
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
"""
).strip()
CONTROLLER_IMPORTS = dedent(
"""
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
"""
).strip()
REPOSITORY_IMPORTS = "import org.springframework.data.jpa.repository.JpaRepository;"
SUPPORTED_SIMPLE_TYPES = {
# primitive/object pairs default to wrapper types for null-safety in DTOs
"String": "String",
"Long": "Long",
"Integer": "Integer",
"Boolean": "Boolean",
"BigDecimal": "BigDecimal",
"UUID": "UUID",
"LocalDate": "LocalDate",
"LocalDateTime": "LocalDateTime",
}
def load_spec(spec_path: str) -> dict:
with open(spec_path, "r", encoding="utf-8") as f:
text = f.read()
if spec_path.endswith('.yml') or spec_path.endswith('.yaml'):
if not _HAS_YAML:
raise SystemExit("PyYAML not installed. Install with `pip install pyyaml` or provide JSON spec.")
return yaml.safe_load(text)
return json.loads(text)
def camel_to_snake(name: str) -> str:
import re as _re
s1 = _re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return _re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def lower_first(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
def ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def write_file(path: str, content: str) -> None:
ensure_dir(os.path.dirname(path))
with open(path, "w", encoding="utf-8") as f:
f.write(content.rstrip() + "\n")
def write_file_if_absent(path: str, content: str) -> None:
if os.path.exists(path):
return
write_file(path, content)
def qualify_imports(types: list[str]) -> str:
imports = []
for t in types:
imp = JAVA_TYPE_IMPORTS.get(t)
if imp and imp not in imports:
imports.append(imp)
return "\n".join(imports)
def indent_block(s: str, n: int = 4) -> str:
prefix = " " * n
return "\n".join((prefix + line if line.strip() else line) for line in (s or "").splitlines())
# ------------------------- Template loading -------------------------
def load_template_text(templates_dir: str | None, filename: str) -> str | None:
if not templates_dir:
return None
candidate = os.path.join(templates_dir, filename)
if os.path.isfile(candidate):
with open(candidate, "r", encoding="utf-8") as f:
return f.read()
return None
def render_template_file(templates_dir: str | None, filename: str, placeholders: dict) -> str | None:
text = load_template_text(templates_dir, filename)
if text is None:
return None
try:
return Template(text).safe_substitute(placeholders).rstrip() + "\n"
except Exception:
# On any template error, fall back to defaults
return None
# ------------------------- Templates -------------------------
def tmpl_domain_model(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
fields_src = []
for f in [id, *fields]:
fields_src.append(f" private final {f['type']} {f['name']};")
ctor_params = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in [id, *fields]])
lombok_import = "import lombok.Getter;" if use_lombok else ""
extra_imports_full = "\n".join(filter(None, [extra_imports, lombok_import]))
class_annot = "@Getter\n" if use_lombok else ""
getters = "\n".join([f" public {f['type']} {('get' + f['name'][0].upper() + f['name'][1:])}() {{ return {f['name']}; }}" for f in [id, *fields]]) if not use_lombok else ""
return dedent(f"""
package {pkg}.domain.model;
{extra_imports_full}
/**
* Domain aggregate for {entity}.
* Keep framework-free; capture invariants in factories/methods.
*/
{class_annot}public class {entity} {{
{os.linesep.join(fields_src)}
private {entity}({ctor_params}) {{
{assigns}
}}
public static {entity} create({ctor_params}) {{
// TODO: add invariant checks
return new {entity}({', '.join([f['name'] for f in [id, *fields]])});
}}
{getters}
}}
""")
def tmpl_domain_repository(pkg: str, entity: str, id_type: str) -> str:
return dedent(f"""
package {pkg}.domain.repository;
import java.util.Optional;
import java.util.List;
import {pkg}.domain.model.{entity};
public interface {entity}Repository {{
{entity} save({entity} aggregate);
Optional<{entity}> findById({id_type} id);
List<{entity}> findAll(int page, int size);
void deleteById({id_type} id);
boolean existsById({id_type} id);
long count();
}}
""")
def tmpl_jpa_entity(pkg: str, entity: str, id: dict, fields: list[dict], relationships: list[dict], use_lombok: bool = False) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
id_ann = "@GeneratedValue(strategy = GenerationType.IDENTITY)" if id["type"] == "Long" and id.get("generated", True) else ""
all_fields = [id, *fields]
fields_src = [
f" private {f['type']} {f['name']};" if f is id else f" @Column(nullable = false)\n private {f['type']} {f['name']};"
for f in all_fields
]
# Relationship fields and imports
rel_fields_src = []
target_imports = []
need_set = False
for r in relationships or []:
rtype = (r.get("type") or "").upper()
name = r.get("name")
target = r.get("target")
if not name or not target or rtype not in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}:
continue
target_import = f"import {pkg}.infrastructure.persistence.{target}Entity;"
if target_import not in target_imports:
target_imports.append(target_import)
annotations = []
if rtype == "ONE_TO_ONE":
mapped_by = r.get("mappedBy")
optional = r.get("optional", True)
if mapped_by:
annotations.append(f" @OneToOne(mappedBy = \"{mapped_by}\", fetch = FetchType.LAZY)")
else:
annotations.append(f" @OneToOne(fetch = FetchType.LAZY, optional = {str(optional).lower()})")
join_col = r.get("joinColumn")
if join_col:
annotations.append(f" @JoinColumn(name = \"{join_col}\")")
field_type = f"{target}Entity"
init = ""
elif rtype == "ONE_TO_MANY":
need_set = True
mapped_by = r.get("mappedBy")
if mapped_by:
annotations.append(f" @OneToMany(mappedBy = \"{mapped_by}\", fetch = FetchType.LAZY)")
else:
annotations.append(" @OneToMany(fetch = FetchType.LAZY)")
join_col = r.get("joinColumn")
if join_col:
annotations.append(f" @JoinColumn(name = \"{join_col}\")")
field_type = f"Set<{target}Entity>"
init = " = new java.util.LinkedHashSet<>()"
else: # MANY_TO_MANY
need_set = True
annotations.append(" @ManyToMany(fetch = FetchType.LAZY)")
jt = r.get("joinTable") or {}
jt_name = jt.get("name")
join_col = jt.get("joinColumn")
inv_join_col = jt.get("inverseJoinColumn")
if jt_name and join_col and inv_join_col:
annotations.append(
f" @JoinTable(name = \"{jt_name}\", joinColumns = @JoinColumn(name = \"{join_col}\"), inverseJoinColumns = @JoinColumn(name = \"{inv_join_col}\"))"
)
field_type = f"Set<{target}Entity>"
init = " = new java.util.LinkedHashSet<>()"
rel_fields_src.append("\n".join(annotations + [f" private {field_type} {name}{init};"]))
rel_block = ("\n" + os.linesep.join(rel_fields_src)) if rel_fields_src else ""
lombok_imports = ("\n".join([
"import lombok.Getter;",
"import lombok.Setter;",
"import lombok.NoArgsConstructor;",
"import lombok.AccessLevel;",
]) if use_lombok else "")
imports_block = "\n".join(filter(None, [JPA_IMPORTS, extra_imports, COLLECTION_IMPORTS if need_set else "", "\n".join(target_imports), lombok_imports]))
imports_block_indented = indent_block(imports_block)
rel_getters = os.linesep.join([
f" public {('Set<' + r['target'] + 'Entity>' if r['type'].upper() != 'ONE_TO_ONE' else r['target'] + 'Entity')} get{r['name'][0].upper() + r['name'][1:]}() {{ return {r['name']}; }}"
for r in (relationships or []) if r.get('name') and r.get('target') and r.get('type', '').upper() in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}
]) if not use_lombok else ""
rel_setters = os.linesep.join([
f" public void set{r['name'][0].upper() + r['name'][1:]}({('Set<' + r['target'] + 'Entity>' if r['type'].upper() != 'ONE_TO_ONE' else r['target'] + 'Entity')} {r['name']}) {{ this.{r['name']} = {r['name']}; }}"
for r in (relationships or []) if r.get('name') and r.get('target') and r.get('type', '').upper() in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}
]) if not use_lombok else ""
class_annots = ("\n".join([
"@Getter",
"@Setter",
"@NoArgsConstructor(access = AccessLevel.PROTECTED)",
]) + "\n") if use_lombok else ""
class_annots_block = indent_block(class_annots.strip()) if use_lombok else ""
fields_getters = os.linesep.join([f" public {f['type']} {('get' + f['name'][0].upper() + f['name'][1:])}() {{ return {f['name']}; }}" for f in all_fields]) if not use_lombok else ""
fields_setters = os.linesep.join([f" public void set{f['name'][0].upper() + f['name'][1:]}({f['type']} {f['name']}) {{ this.{f['name']} = {f['name']}; }}" for f in all_fields]) if not use_lombok else ""
return dedent(f"""
package {pkg}.infrastructure.persistence;
{imports_block}
@Entity
@Table(name = "{camel_to_snake(entity)}")
{class_annots}public class {entity}Entity {{
@Id
{id_ann}
private {id['type']} {id['name']};
{os.linesep.join(fields_src[1:])}
{rel_block}
{'' if use_lombok else f'protected {entity}Entity() {{ /* for JPA */ }}'}
public {entity}Entity({', '.join([f['type'] + ' ' + f['name'] for f in all_fields])}) {{
{os.linesep.join([f" this.{f['name']} = {f['name']};" for f in all_fields])}
}}
{fields_getters}
{fields_setters}
{rel_getters}
{rel_setters}
}}
""")
def tmpl_spring_data_repo(pkg: str, entity: str, id_type: str) -> str:
return dedent(f"""
package {pkg}.infrastructure.persistence;
{indent_block(REPOSITORY_IMPORTS)}
public interface {entity}JpaRepository extends JpaRepository<{entity}Entity, {id_type}> {{}}
""")
def tmpl_persistence_adapter(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
return dedent(f"""
package {pkg}.infrastructure.persistence;
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
import {pkg}.domain.model.{entity};
import {pkg}.domain.repository.{entity}Repository;
import org.springframework.stereotype.Component;
{('import lombok.RequiredArgsConstructor;' if use_lombok else '')}
@Component
{('@RequiredArgsConstructor' if use_lombok else '')}
public class {entity}RepositoryAdapter implements {entity}Repository {{
private final {entity}JpaRepository jpa;
{'' if use_lombok else f'public {entity}RepositoryAdapter({entity}JpaRepository jpa) {{\n this.jpa = jpa;\n }}'}
@Override
public {entity} save({entity} aggregate) {{
{entity}Entity e = toEntity(aggregate);
e = jpa.save(e);
return toDomain(e);
}}
@Override
public Optional<{entity}> findById({id['type']} id) {{
return jpa.findById(id).map(this::toDomain);
}}
@Override
public List<{entity}> findAll(int page, int size) {{
return jpa.findAll(org.springframework.data.domain.PageRequest.of(page, size))
.stream().map(this::toDomain).collect(java.util.stream.Collectors.toList());
}}
@Override
public void deleteById({id['type']} id) {{
jpa.deleteById(id);
}}
@Override
public boolean existsById({id['type']} id) {{
return jpa.existsById(id);
}}
@Override
public long count() {{
return jpa.count();
}}
private {entity}Entity toEntity({entity} a) {{
return new {entity}Entity({', '.join(['a.get' + id['name'][0].upper() + id['name'][1:] + '()'] + ['a.get' + f['name'][0].upper() + f['name'][1:] + '()' for f in fields])});
}}
private {entity} toDomain({entity}Entity e) {{
return {entity}.create({', '.join(['e.get' + id['name'][0].upper() + id['name'][1:] + '()'] + ['e.get' + f['name'][0].upper() + f['name'][1:] + '()' for f in fields])});
}}
}}
""")
def tmpl_application_service(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
lc_entity = lower_first(entity)
dto_req = f"{entity}Request"
dto_res = f"{entity}Response"
params = ", ".join([f"request.{f['name']}()" for f in [id, *fields]])
update_params = ", ".join([f"request.{f['name']}()" for f in [*fields]])
return dedent(f"""
package {pkg}.application.service;
{indent_block(SPRING_IMPORTS)}
import java.util.List;
import {pkg}.domain.model.{entity};
import {pkg}.domain.repository.{entity}Repository;
import {pkg}.presentation.dto.{dto_req};
import {pkg}.presentation.dto.{dto_res};
@Service
@Transactional
public class {entity}Service {{
private final {entity}Repository repository;
public {entity}Service({entity}Repository repository) {{
this.repository = repository;
}}
public {dto_res} create({dto_req} request) {{
{entity} {lc_entity} = {entity}.create({params});
{lc_entity} = repository.save({lc_entity});
return {dto_res}.from({lc_entity});
}}
@Transactional(readOnly = true)
public {dto_res} get({id['type']} id) {{
return repository.findById(id).map({dto_res}::from)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND));
}}
public {dto_res} update({dto_req} request) {{
// In a real app, load existing aggregate and apply changes
{entity} updated = {entity}.create(request.{id['name']}(), {update_params});
updated = repository.save(updated);
return {dto_res}.from(updated);
}}
public void delete({id['type']} id) {{
repository.deleteById(id);
}}
@Transactional(readOnly = true)
public java.util.List<{dto_res}> list(int page, int size) {{
return repository.findAll(page, size).stream().map({dto_res}::from).collect(java.util.stream.Collectors.toList());
}}
}}
""")
def tmpl_dto_request(pkg: str, entity: str, id: dict, fields: list[dict]) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
comps = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
return dedent(f"""
package {pkg}.presentation.dto;
{extra_imports}
public record {entity}Request({comps}) {{ }}
""")
def tmpl_dto_response(pkg: str, entity: str, id: dict, fields: list[dict]) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
comps = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
getters = ", ".join([f"aggregate.get{f['name'][0].upper() + f['name'][1:]}()" for f in [id, *fields]])
return dedent(f"""
package {pkg}.presentation.dto;
{indent_block(extra_imports)}
import {pkg}.domain.model.{entity};
public record {entity}Response({comps}) {{
public static {entity}Response from({entity} aggregate) {{
return new {entity}Response({getters});
}}
}}
""")
def tmpl_controller(pkg: str, entity: str, id: dict, use_lombok: bool = False) -> str:
base = f"/api/{camel_to_snake(entity)}s" # naive pluralization with 's'
dto_req = f"{entity}Request"
dto_res = f"{entity}Response"
var_name = lower_first(id['name'])
path_seg = "/{" + var_name + "}"
return dedent(f"""
package {pkg}.presentation.rest;
{indent_block(CONTROLLER_IMPORTS)}
import {pkg}.application.service.{entity}Service;
import {pkg}.presentation.dto.{dto_req};
import {pkg}.presentation.dto.{dto_res};
@RestController
@RequestMapping("{base}")
public class {entity}Controller {{
private final {entity}Service service;
public {entity}Controller({entity}Service service) {{
this.service = service;
}}
@PostMapping
public ResponseEntity<{dto_res}> create(@RequestBody @Valid {dto_req} request) {{
var created = service.create(request);
return ResponseEntity.status(201).body(created);
}}
@GetMapping("{path_seg}")
public ResponseEntity<{dto_res}> get(@PathVariable {id['type']} {var_name}) {{
return ResponseEntity.ok(service.get({var_name}));
}}
@PutMapping("{path_seg}")
public ResponseEntity<{dto_res}> update(@PathVariable {id['type']} {var_name},
@RequestBody @Valid {dto_req} request) {{
// In a real app: ensure path id == request id
return ResponseEntity.ok(service.update(request));
}}
@DeleteMapping("{path_seg}")
public ResponseEntity<Void> delete(@PathVariable {id['type']} {var_name}) {{
service.delete({var_name});
return ResponseEntity.noContent().build();
}}
@GetMapping
public ResponseEntity<java.util.List<{dto_res}>> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {{
return ResponseEntity.ok(service.list(page, size));
}}
}}
""")
# ------------------------- Main -------------------------
def main():
parser = argparse.ArgumentParser(description="Generate Spring Boot CRUD boilerplate from entity spec")
parser.add_argument("--spec", required=True, help="Path to entity spec (JSON or YAML)")
parser.add_argument("--package", required=True, help="Base package, e.g., com.example.product")
parser.add_argument("--output", default="./generated", help="Output root directory")
parser.add_argument("--lombok", action="store_true", help="Use Lombok annotations for getters/setters where applicable")
parser.add_argument("--templates-dir", help="Directory with override templates (*.tpl). If omitted, auto-detects ../templates relative to this script if present.")
args = parser.parse_args()
spec = load_spec(args.spec)
entity = spec.get("entity")
if not entity or not re.match(r"^[A-Z][A-Za-z0-9_]*$", entity):
raise SystemExit("Spec 'entity' must be a PascalCase identifier, e.g., 'Product'")
id_spec = spec.get("id") or {"name": "id", "type": "Long", "generated": True}
if id_spec["type"] not in SUPPORTED_SIMPLE_TYPES:
raise SystemExit(f"Unsupported id type: {id_spec['type']}")
fields = spec.get("fields", [])
relationships = spec.get("relationships", [])
for f in fields:
if f["type"] not in SUPPORTED_SIMPLE_TYPES:
raise SystemExit(f"Unsupported field type: {f['name']} -> {f['type']}")
feature_name = entity.lower()
base_pkg = args.package
out_root = os.path.abspath(args.output)
java_root = os.path.join(out_root, "src/main/java", base_pkg.replace(".", "/"))
# Paths
paths = {
"domain_model": os.path.join(java_root, "domain/model", f"{entity}.java"),
"domain_repo": os.path.join(java_root, "domain/repository", f"{entity}Repository.java"),
"domain_service": os.path.join(java_root, "domain/service", f"{entity}Service.java"),
"jpa_entity": os.path.join(java_root, "infrastructure/persistence", f"{entity}Entity.java"),
"spring_data_repo": os.path.join(java_root, "infrastructure/persistence", f"{entity}JpaRepository.java"),
"persistence_adapter": os.path.join(java_root, "infrastructure/persistence", f"{entity}RepositoryAdapter.java"),
"app_service_create": os.path.join(java_root, "application/service", f"Create{entity}Service.java"),
"app_service_get": os.path.join(java_root, "application/service", f"Get{entity}Service.java"),
"app_service_update": os.path.join(java_root, "application/service", f"Update{entity}Service.java"),
"app_service_delete": os.path.join(java_root, "application/service", f"Delete{entity}Service.java"),
"app_service_list": os.path.join(java_root, "application/service", f"List{entity}Service.java"),
"dto_req": os.path.join(java_root, "presentation/dto", f"{entity}Request.java"),
"dto_res": os.path.join(java_root, "presentation/dto", f"{entity}Response.java"),
"controller": os.path.join(java_root, "presentation/rest", f"{entity}Controller.java"),
"ex_not_found": os.path.join(java_root, "application/exception", f"{entity}NotFoundException.java"),
"ex_exist": os.path.join(java_root, "application/exception", f"{entity}ExistException.java"),
"entity_exception_handler": os.path.join(java_root, "presentation/rest", f"{entity}ExceptionHandler.java"),
}
# Resolve templates directory (required; no fallback to built-ins)
script_dir = os.path.dirname(os.path.abspath(__file__))
default_templates_dir = os.path.normpath(os.path.join(script_dir, "..", "templates"))
templates_dir = args.templates_dir or (default_templates_dir if os.path.isdir(default_templates_dir) else None)
if not templates_dir or not os.path.isdir(templates_dir):
raise SystemExit("Templates directory not found. Provide --templates-dir or create: " + default_templates_dir)
required_templates = [
"DomainModel.java.tpl",
"DomainRepository.java.tpl",
"DomainService.java.tpl",
"JpaEntity.java.tpl",
"SpringDataRepository.java.tpl",
"PersistenceAdapter.java.tpl",
"CreateService.java.tpl",
"GetService.java.tpl",
"UpdateService.java.tpl",
"DeleteService.java.tpl",
"ListService.java.tpl",
"DtoRequest.java.tpl",
"DtoResponse.java.tpl",
"Controller.java.tpl",
"NotFoundException.java.tpl",
"ExistException.java.tpl",
"EntityExceptionHandler.java.tpl",
]
missing = [name for name in required_templates if load_template_text(templates_dir, name) is None]
if missing:
raise SystemExit("Missing required templates: " + ", ".join(missing) + " in " + templates_dir)
# Build dynamic code fragments for templates
all_fields = [id_spec, *fields]
used_types = {f["type"] for f in all_fields}
extra_imports = qualify_imports(sorted(used_types))
def cap(s: str) -> str:
return s[:1].upper() + s[1:] if s else s
# Domain fragments
final_kw = "" if args.lombok else "final "
domain_fields_decls = "\n".join([f" private {final_kw}{f['type']} {f['name']};" for f in all_fields])
domain_ctor_params = ", ".join([f"{f['type']} {f['name']}" for f in all_fields])
domain_assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in all_fields])
domain_getters = ("\n".join([f" public {f['type']} get{cap(f['name'])}() {{ return {f['name']}; }}" for f in all_fields]) if not args.lombok else "")
model_constructor_block = (f" private {entity}({domain_ctor_params}) {{\n{domain_assigns}\n }}" if not args.lombok else "")
all_names_csv = ", ".join([f["name"] for f in all_fields])
# JPA fragments
def _jpa_field_decl(f: dict) -> str:
if f is id_spec:
lines = [" @Id"]
if bool(id_spec.get("generated", False)) and id_spec["type"] in ("Long", "Integer"):
lines.append(" @GeneratedValue(strategy = GenerationType.IDENTITY)")
lines.append(f" private {f['type']} {f['name']};")
return "\n".join(lines)
return f" @Column(nullable = false)\n private {f['type']} {f['name']};"
jpa_fields_decls = "\n".join([_jpa_field_decl(f) for f in all_fields])
jpa_ctor_params = domain_ctor_params
jpa_assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in all_fields])
jpa_getters_setters = "\n".join([
f" public {f['type']} get{cap(f['name'])}() {{ return {f['name']}; }}\n public void set{cap(f['name'])}({f['type']} {f['name']}) {{ this.{f['name']} = {f['name']}; }}" for f in all_fields
])
# DTO components
id_generated = bool(id_spec.get("generated", False))
dto_response_components = ", ".join([f"{f['type']} {f['name']}" for f in all_fields])
dto_request_fields = (fields if id_generated else all_fields)
dto_request_components = ", ".join([f"{f['type']} {f['name']}" for f in dto_request_fields])
# Mapping fragments
adapter_to_entity_args = ", ".join([f"a.get{cap(f['name'])}()" for f in all_fields])
adapter_to_domain_args = ", ".join([f"e.get{cap(f['name'])}()" for f in all_fields])
if id_generated:
request_all_args = ", ".join(["null", *[f"request.{f['name']}()" for f in fields]])
else:
request_all_args = ", ".join([f"request.{id_spec['name']}()", *[f"request.{f['name']}()" for f in fields]])
response_from_agg_args = ", ".join([f"agg.get{cap(f['name'])}()" for f in all_fields])
list_map_response_args = ", ".join([f"a.get{cap(f['name'])}()" for f in all_fields])
update_create_args = ", ".join([id_spec["name"], *[f"request.{f['name']}()" for f in fields]])
mapper_create_args = ", ".join(["id", *[f"request.{f['name']}()" for f in fields]])
create_id_arg = ("null" if id_generated else f"request.{id_spec['name']}()")
table_name = camel_to_snake(entity)
base_path = f"/api/{table_name}"
# Common placeholders for external templates
# Lombok-related placeholders
# Domain model should only have @Getter for DDD immutability
lombok_domain_imports = "import lombok.Getter;" if args.lombok else ""
lombok_domain_annotations = "@Getter" if args.lombok else ""
lombok_domain_annotations_block = ("\n" + lombok_domain_annotations) if lombok_domain_annotations else ""
lombok_model_imports = "import lombok.Getter;\nimport lombok.Setter;\nimport lombok.AllArgsConstructor;" if args.lombok else ""
lombok_common_imports = "import lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;" if args.lombok else ""
model_annotations = "@Getter\n@Setter\n@AllArgsConstructor" if args.lombok else ""
service_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
controller_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
adapter_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
# annotation blocks that include a leading newline when present to avoid empty lines
service_annotations_block = ("\n" + service_annotations) if service_annotations else ""
controller_annotations_block = ("\n" + controller_annotations) if controller_annotations else ""
adapter_annotations_block = ("\n" + adapter_annotations) if adapter_annotations else ""
model_annotations_block = ("\n" + model_annotations) if model_annotations else ""
# Common placeholders for external templates
placeholders = {
"entity": entity,
"Entity": entity,
"EntityRequest": f"{entity}Request",
"EntityResponse": f"{entity}Response",
"entity_lower": lower_first(entity),
"package": base_pkg,
"Package": base_pkg.replace(".", "/"),
"table_name": table_name,
"base_path": base_path,
"id_type": id_spec["type"],
"id_name": id_spec["name"],
"id_name_lower": lower_first(id_spec["name"]),
"id_generated": str(id_generated).lower(),
"fields": fields,
"all_fields": all_fields,
"extra_imports": extra_imports,
"final_kw": final_kw,
"domain_fields_decls": domain_fields_decls,
"domain_ctor_params": domain_ctor_params,
"domain_assigns": domain_assigns,
"domain_getters": domain_getters,
"model_constructor_block": model_constructor_block,
"all_names_csv": all_names_csv,
"jpa_fields_decls": jpa_fields_decls,
"jpa_ctor_params": jpa_ctor_params,
"jpa_assigns": jpa_assigns,
"jpa_getters_setters": jpa_getters_setters,
"dto_response_components": dto_response_components,
"dto_request_components": dto_request_components,
"adapter_to_entity_args": adapter_to_entity_args,
"adapter_to_domain_args": adapter_to_domain_args,
"request_all_args": request_all_args,
"response_from_agg_args": response_from_agg_args,
"list_map_response_args": list_map_response_args,
"update_create_args": update_create_args,
"mapper_create_args": mapper_create_args,
"create_id_arg": create_id_arg,
# Domain-specific Lombok placeholders (DDD-compliant)
"lombok_domain_imports": lombok_domain_imports,
"lombok_domain_annotations_block": lombok_domain_annotations_block,
# Infrastructure/infrastructure Lombok placeholders
"lombok_model_imports": lombok_model_imports,
"lombok_common_imports": lombok_common_imports,
"model_annotations": model_annotations,
"service_annotations": service_annotations,
"controller_annotations": controller_annotations,
"adapter_annotations": adapter_annotations,
"service_annotations_block": service_annotations_block,
"controller_annotations_block": controller_annotations_block,
"adapter_annotations_block": adapter_annotations_block,
"model_annotations_block": model_annotations_block,
# Constructor placeholders
"controller_constructor": "",
"adapter_constructor": "",
"create_constructor": "",
"update_constructor": "",
"get_constructor": "",
"list_constructor": "",
"delete_constructor": "",
"domain_service_constructor": "",
}
def _render(name, placeholders_dict):
c = render_template_file(templates_dir, name, placeholders_dict)
if c is None: raise SystemExit(f"Template render failed: {name}")
c = (c.replace("$controller_constructor", placeholders_dict.get("controller_constructor", ""))
.replace("$adapter_constructor", placeholders_dict.get("adapter_constructor", ""))
.replace("$create_constructor", placeholders_dict.get("create_constructor", ""))
.replace("$update_constructor", placeholders_dict.get("update_constructor", ""))
.replace("$get_constructor", placeholders_dict.get("get_constructor", ""))
.replace("$list_constructor", placeholders_dict.get("list_constructor", ""))
.replace("$delete_constructor", placeholders_dict.get("delete_constructor", ""))
.replace("$domain_service_constructor", placeholders_dict.get("domain_service_constructor", "")))
return c
# Write files (templates only, fail on error)
content = _render("DomainModel.java.tpl", placeholders)
write_file(paths["domain_model"], content)
content = _render("DomainRepository.java.tpl", placeholders)
write_file(paths["domain_repo"], content)
content = _render("DomainService.java.tpl", placeholders)
write_file(paths["domain_service"], content)
content = _render("JpaEntity.java.tpl", placeholders)
write_file(paths["jpa_entity"], content)
content = _render("SpringDataRepository.java.tpl", placeholders)
write_file(paths["spring_data_repo"], content)
content = _render("PersistenceAdapter.java.tpl", placeholders)
write_file(paths["persistence_adapter"], content)
content = _render("CreateService.java.tpl", placeholders)
write_file(paths["app_service_create"], content)
content = _render("GetService.java.tpl", placeholders)
write_file(paths["app_service_get"], content)
content = _render("UpdateService.java.tpl", placeholders)
write_file(paths["app_service_update"], content)
content = _render("DeleteService.java.tpl", placeholders)
write_file(paths["app_service_delete"], content)
content = _render("ListService.java.tpl", placeholders)
write_file(paths["app_service_list"], content)
content = _render("DtoRequest.java.tpl", placeholders)
write_file(paths["dto_req"], content)
content = _render("DtoResponse.java.tpl", placeholders)
write_file(paths["dto_res"], content)
content = _render("Controller.java.tpl", placeholders)
write_file(paths["controller"], content)
# Exceptions
content = _render("NotFoundException.java.tpl", placeholders)
write_file(paths["ex_not_found"], content)
content = _render("ExistException.java.tpl", placeholders)
write_file(paths["ex_exist"], content)
content = _render("EntityExceptionHandler.java.tpl", placeholders)
write_file(paths["entity_exception_handler"], content)
# Helpful README
readme = dedent(f"""
# Generated CRUD Feature: {entity}
Base package: {base_pkg}
Structure:
- domain/model/{entity}.java
- domain/repository/{entity}Repository.java
- infrastructure/persistence/{entity}Entity.java
- infrastructure/persistence/{entity}JpaRepository.java
- infrastructure/persistence/{entity}RepositoryAdapter.java
- application/service/Create{entity}Service.java
- application/service/Get{entity}Service.java
- application/service/Update{entity}Service.java
- application/service/Delete{entity}Service.java
- application/service/List{entity}Service.java
- presentation/dto/{entity}Request.java
- presentation/dto/{entity}Response.java
- presentation/rest/{entity}Controller.java
Next steps:
- Add validation and invariants in domain aggregate
- Secure endpoints and add tests (unit + @DataJpaTest + Testcontainers)
- Wire into your Spring Boot app (component scan should pick up beans)
""")
write_file(os.path.join(out_root, "README-GENERATED.md"), readme)
print(f"CRUD boilerplate generated under: {out_root}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)