Initial commit
This commit is contained in:
108
skills/spring-boot-crud-patterns/SKILL.md
Normal file
108
skills/spring-boot-crud-patterns/SKILL.md
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: spring-boot-crud-patterns
|
||||
description: Provide repeatable CRUD workflows for Spring Boot 3 services with Spring Data JPA and feature-focused architecture; apply when modeling aggregates, repositories, controllers, and DTOs for REST APIs.
|
||||
allowed-tools: Read, Write, Bash
|
||||
category: backend
|
||||
tags: [spring-boot, java, ddd, rest-api, crud, jpa, feature-architecture]
|
||||
version: 1.1.0
|
||||
---
|
||||
|
||||
# Spring Boot CRUD Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
Deliver feature-aligned CRUD services that separate domain, application, presentation, and infrastructure layers while preserving Spring Boot 3.5+ conventions. This skill distills the essential workflow and defers detailed code listings to reference files for progressive disclosure.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Implement REST endpoints for create/read/update/delete workflows backed by Spring Data JPA.
|
||||
- Refine feature packages following DDD-inspired architecture with aggregates, repositories, and application services.
|
||||
- Introduce DTO records, request validation, and controller mappings for external clients.
|
||||
- Diagnose CRUD regressions, repository contracts, or transaction boundaries in existing Spring Boot services.
|
||||
- Trigger phrases: **"implement Spring CRUD controller"**, **"refine feature-based repository"**, **"map DTOs for JPA aggregate"**, **"add pagination to REST list endpoint"**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Java 17+ project using Spring Boot 3.5.x (or later) with `spring-boot-starter-web` and `spring-boot-starter-data-jpa`.
|
||||
- Constructor injection enabled (Lombok `@RequiredArgsConstructor` or explicit constructors).
|
||||
- Access to a relational database (Testcontainers recommended for integration tests).
|
||||
- Familiarity with validation (`jakarta.validation`) and error handling (`ResponseStatusException`).
|
||||
|
||||
## Quickstart Workflow
|
||||
|
||||
1. **Establish Feature Structure**
|
||||
Create `feature/<name>/` directories for `domain`, `application`, `presentation`, and `infrastructure`.
|
||||
2. **Model the Aggregate**
|
||||
Define domain entities and value objects without Spring dependencies; capture invariants in methods such as `create` and `update`.
|
||||
3. **Expose Domain Ports**
|
||||
Declare repository interfaces in `domain/repository` describing persistence contracts.
|
||||
4. **Provide Infrastructure Adapter**
|
||||
Implement Spring Data adapters in `infrastructure/persistence` that map domain models to JPA entities and delegate to `JpaRepository`.
|
||||
5. **Implement Application Services**
|
||||
Create transactional use cases under `application/service` that orchestrate aggregates, repositories, and mapping logic.
|
||||
6. **Publish REST Controllers**
|
||||
Map DTO records under `presentation/rest`, expose endpoints with proper status codes, and wire validation annotations.
|
||||
7. **Validate with Tests**
|
||||
Run unit tests for domain logic and repository/service tests with Testcontainers for persistence verification.
|
||||
|
||||
Consult `references/examples-product-feature.md` for complete code listings that align with each step.
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Domain Layer
|
||||
|
||||
- Define immutable aggregates with factory methods (`Product.create`) to centralize invariants.
|
||||
- Use value objects (`Money`, `Stock`) to enforce type safety and encapsulate validation.
|
||||
- Keep domain objects framework-free; avoid `@Entity` annotations in the domain package when using adapters.
|
||||
|
||||
### Application Layer
|
||||
|
||||
- Wrap use cases in `@Service` classes using constructor injection and `@Transactional`.
|
||||
- Map requests to domain operations and persist through domain repositories.
|
||||
- Return response DTOs or records produced by dedicated mappers to decouple domain from transport.
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
- Implement adapters that translate between domain aggregates and JPA entities; prefer MapStruct or manual mappers for clarity.
|
||||
- Configure repositories with Spring Data interfaces (e.g., `JpaRepository<ProductEntity, String>`) and custom queries for pagination or batch updates.
|
||||
- Externalize persistence properties (naming strategies, DDL mode) via `application.yml`; see `references/spring-official-docs.md`.
|
||||
|
||||
### Presentation Layer
|
||||
|
||||
- Structure controllers by feature (`ProductController`) and expose REST paths (`/api/products`).
|
||||
- Return `ResponseEntity` with appropriate codes: `201 Created` on POST, `200 OK` on GET/PUT/PATCH, `204 No Content` on DELETE.
|
||||
- Apply `@Valid` on request DTOs and handle errors with `@ControllerAdvice` or `ResponseStatusException`.
|
||||
|
||||
## Validation and Observability
|
||||
|
||||
- Write unit tests that assert domain invariants and repository contracts; refer to `references/examples-product-feature.md` integration test snippets.
|
||||
- Use `@DataJpaTest` and Testcontainers to validate persistence mapping, pagination, and batch operations.
|
||||
- Surface health and metrics through Spring Boot Actuator; monitor CRUD throughput and error rates.
|
||||
- Log key actions at `info` for lifecycle events (create, update, delete) and use structured logging for audit trails.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Favor feature modules with clear boundaries; colocate domain, application, and presentation code per aggregate.
|
||||
- Keep DTOs immutable via Java records; convert domain types at the service boundary.
|
||||
- Guard write operations with transactions and optimistic locking where concurrency matters.
|
||||
- Normalize pagination defaults (page, size, sort) and document query parameters.
|
||||
- Capture links between commands and events where integration with messaging or auditing is required.
|
||||
|
||||
## Constraints and Warnings
|
||||
|
||||
- Avoid exposing JPA entities directly in controllers to prevent lazy-loading leaks and serialization issues.
|
||||
- Do not mix field injection with constructor injection; maintain immutability for easier testing.
|
||||
- Refrain from embedding business logic in controllers or repository adapters; keep it in domain/application layers.
|
||||
- Validate input aggressively to prevent constraint violations and produce consistent error payloads.
|
||||
- Ensure migrations (Liquibase/Flyway) mirror aggregate evolution before deploying schema changes.
|
||||
|
||||
## References
|
||||
|
||||
- [HTTP method matrix, annotation catalog, DTO patterns.](references/crud-reference.md)
|
||||
- [Progressive examples from starter to advanced feature implementation.](references/examples-product-feature.md)
|
||||
- [Excerpts from official Spring guides and Spring Boot reference documentation.](references/spring-official-docs.md)
|
||||
- [Python generator to scaffold CRUD boilerplate from entity spec.](scripts/generate_crud_boilerplate.py) Usage: `python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py --spec entity.json --package com.example.product --output ./generated`
|
||||
- Templates required: place .tpl files in `skills/spring-boot/spring-boot-crud-patterns/templates/` or pass `--templates-dir <path>`; no fallback to built-ins. See `templates/README.md`.
|
||||
- Usage guide: [references/generator-usage.md](references/generator-usage.md)
|
||||
- Example spec: `skills/spring-boot/spring-boot-crud-patterns/assets/specs/product.json`
|
||||
- Example with relationships: `skills/spring-boot/spring-boot-crud-patterns/assets/specs/product_with_rel.json`
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"entity": "Order",
|
||||
"id": { "name": "id", "type": "Long", "generated": true },
|
||||
"fields": [
|
||||
{ "name": "orderNumber", "type": "String" },
|
||||
{ "name": "total", "type": "BigDecimal" }
|
||||
],
|
||||
"relationships": [
|
||||
{ "type": "ONE_TO_MANY", "name": "items", "target": "OrderItem", "mappedBy": "order" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# CRUD Reference (Quick)
|
||||
|
||||
- Status codes: POST 201, GET 200, PUT/PATCH 200, DELETE 204.
|
||||
- Validation: jakarta.validation annotations on DTOs.
|
||||
- Repositories: feature-scoped interfaces + Spring Data adapters.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Product Feature Examples (Skeleton)
|
||||
|
||||
Feature structure:
|
||||
- domain/model/Product.java
|
||||
- domain/repository/ProductRepository.java
|
||||
- infrastructure/persistence/ProductEntity.java
|
||||
- infrastructure/persistence/ProductJpaRepository.java
|
||||
- infrastructure/persistence/ProductRepositoryAdapter.java
|
||||
- application/service/ProductService.java
|
||||
- presentation/dto/ProductRequest.java
|
||||
- presentation/dto/ProductResponse.java
|
||||
- presentation/rest/ProductController.java
|
||||
@@ -0,0 +1,35 @@
|
||||
# CRUD Generator Usage
|
||||
|
||||
Quick start:
|
||||
|
||||
```
|
||||
python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
|
||||
--spec skills/spring-boot/spring-boot-crud-patterns/assets/specs/product.json \
|
||||
--package com.example.product \
|
||||
--output ./generated \
|
||||
--templates-dir skills/spring-boot/spring-boot-crud-patterns/templates [--lombok]
|
||||
```
|
||||
|
||||
Spec (JSON/YAML):
|
||||
- entity: PascalCase name (e.g., Product)
|
||||
- id: { name, type (Long|UUID|...), generated: true|false }
|
||||
- fields: array of { name, type }
|
||||
- relationships: optional (currently model as FK ids in fields)
|
||||
|
||||
What gets generated:
|
||||
- REST controller at /v1/{resources} with POST 201 + Location header
|
||||
- Pageable list endpoint returning PageResponse<T>
|
||||
- Application mapper (application/mapper/${Entity}Mapper) for DTO↔Domain
|
||||
- Exception types: ${Entity}NotFoundException, ${Entity}ExistException + ${Entity}ExceptionHandler
|
||||
- GlobalExceptionHandler with validation + DataIntegrityViolationException→409
|
||||
|
||||
DTOs:
|
||||
- Request excludes id when id.generated=true
|
||||
- Response always includes id
|
||||
|
||||
JPA entity:
|
||||
- @Id with @GeneratedValue(IDENTITY) for numeric generated ids
|
||||
|
||||
Notes:
|
||||
- Provide all templates in templates/ (see templates/README.md)
|
||||
- Use --lombok to add Lombok annotations without introducing blank lines between annotations
|
||||
@@ -0,0 +1,5 @@
|
||||
# Spring Docs Pointers
|
||||
|
||||
- Spring Boot Reference Guide
|
||||
- Spring Data JPA Reference
|
||||
- Validation (Jakarta Validation)
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
898
skills/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py
Executable file
898
skills/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py
Executable 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)
|
||||
@@ -0,0 +1,60 @@
|
||||
package $package.presentation.rest;
|
||||
|
||||
$lombok_common_imports
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import $package.application.service.Create${entity}Service;
|
||||
import $package.application.service.Get${entity}Service;
|
||||
import $package.application.service.Update${entity}Service;
|
||||
import $package.application.service.Delete${entity}Service;
|
||||
import $package.application.service.List${entity}Service;
|
||||
import $package.presentation.dto.$EntityRequest;
|
||||
import $package.presentation.dto.$EntityResponse;
|
||||
import $package.presentation.dto.PageResponse;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
@RestController$controller_annotations_block
|
||||
@RequestMapping("$base_path")
|
||||
public class ${entity}Controller {
|
||||
|
||||
private final Create${entity}Service createService;
|
||||
private final Get${entity}Service getService;
|
||||
private final Update${entity}Service updateService;
|
||||
private final Delete${entity}Service deleteService;
|
||||
private final List${entity}Service listService;
|
||||
|
||||
$controller_constructor
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<$EntityResponse> create(@RequestBody @Valid $EntityRequest request) {
|
||||
$EntityResponse created = createService.create(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.header("Location", "$base_path/" + created.$id_name())
|
||||
.body(created);
|
||||
}
|
||||
|
||||
@GetMapping("/{${id_name_lower}}")
|
||||
public ResponseEntity<$EntityResponse> get(@PathVariable $id_type ${id_name_lower}) {
|
||||
return ResponseEntity.ok(getService.get(${id_name_lower}));
|
||||
}
|
||||
|
||||
@PutMapping("/{${id_name_lower}}")
|
||||
public ResponseEntity<$EntityResponse> update(@PathVariable $id_type ${id_name_lower},
|
||||
@RequestBody @Valid $EntityRequest request) {
|
||||
return ResponseEntity.ok(updateService.update(${id_name_lower}, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{${id_name_lower}}")
|
||||
public ResponseEntity<Void> delete(@PathVariable $id_type ${id_name_lower}) {
|
||||
deleteService.delete(${id_name_lower});
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<PageResponse<$EntityResponse>> list(Pageable pageable) {
|
||||
return ResponseEntity.ok(listService.list(pageable.getPageNumber(), pageable.getPageSize()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package $package.application.service;
|
||||
|
||||
$lombok_common_imports
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import $package.domain.model.$entity;
|
||||
import $package.domain.service.${entity}Service;
|
||||
import $package.application.mapper.${entity}Mapper;
|
||||
import $package.application.exception.${entity}ExistException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import $package.presentation.dto.$EntityRequest;
|
||||
import $package.presentation.dto.$EntityResponse;
|
||||
|
||||
@Service$service_annotations_block
|
||||
@Transactional
|
||||
public class Create${entity}Service {
|
||||
|
||||
private final ${entity}Service ${entity_lower}Service;
|
||||
private final ${entity}Mapper mapper;
|
||||
|
||||
$create_constructor
|
||||
|
||||
public $EntityResponse create($EntityRequest request) {
|
||||
try {
|
||||
$entity agg = mapper.toAggregate($create_id_arg, request);
|
||||
agg = ${entity_lower}Service.save(agg);
|
||||
return mapper.toResponse(agg);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new ${entity}ExistException("Duplicate $entity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package $package.application.service;
|
||||
|
||||
$lombok_common_imports
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import $package.domain.service.${entity}Service;
|
||||
import $package.application.exception.${entity}NotFoundException;
|
||||
|
||||
@Service$service_annotations_block
|
||||
@Transactional
|
||||
public class Delete${entity}Service {
|
||||
|
||||
private final ${entity}Service ${entity_lower}Service;
|
||||
|
||||
$delete_constructor
|
||||
|
||||
public void delete($id_type $id_name) {
|
||||
if (!${entity_lower}Service.existsById($id_name)) {
|
||||
throw new ${entity}NotFoundException($id_name);
|
||||
}
|
||||
${entity_lower}Service.deleteById($id_name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package $package.domain.model;
|
||||
|
||||
$lombok_domain_imports
|
||||
$extra_imports
|
||||
|
||||
$lombok_domain_annotations_block
|
||||
public class $entity {
|
||||
$domain_fields_decls
|
||||
|
||||
private $entity($domain_ctor_params) {
|
||||
$domain_assigns
|
||||
}
|
||||
|
||||
public static $entity create($domain_ctor_params) {
|
||||
// TODO: add invariant checks
|
||||
return new $entity($all_names_csv);
|
||||
}
|
||||
|
||||
$domain_getters
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package $package.domain.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import $package.domain.model.$entity;
|
||||
|
||||
public interface ${entity}Repository {
|
||||
$entity save($entity aggregate);
|
||||
Optional<$entity> findById($id_type $id_name);
|
||||
List<$entity> findAll(int page, int size);
|
||||
void deleteById($id_type $id_name);
|
||||
boolean existsById($id_type $id_name);
|
||||
long count();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package $package.domain.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
$lombok_common_imports
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import $package.domain.model.$entity;
|
||||
import $package.domain.repository.${entity}Repository;
|
||||
|
||||
@Service$service_annotations_block
|
||||
@Transactional
|
||||
public class ${entity}Service {
|
||||
|
||||
private final ${entity}Repository repository;
|
||||
|
||||
$domain_service_constructor
|
||||
|
||||
public $entity save($entity aggregate) {
|
||||
return repository.save(aggregate);
|
||||
}
|
||||
|
||||
public Optional<$entity> findById($id_type $id_name) {
|
||||
return repository.findById($id_name);
|
||||
}
|
||||
|
||||
public List<$entity> findAll(int page, int size) {
|
||||
return repository.findAll(page, size);
|
||||
}
|
||||
|
||||
public void deleteById($id_type $id_name) {
|
||||
repository.deleteById($id_name);
|
||||
}
|
||||
|
||||
public boolean existsById($id_type $id_name) {
|
||||
return repository.existsById($id_name);
|
||||
}
|
||||
|
||||
public long count() {
|
||||
return repository.count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
$extra_imports
|
||||
|
||||
public record $EntityRequest($dto_request_components) { }
|
||||
@@ -0,0 +1,5 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
$extra_imports
|
||||
|
||||
public record $EntityResponse($dto_response_components) { }
|
||||
@@ -0,0 +1,35 @@
|
||||
package $package.presentation.rest;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import $package.application.exception.${entity}NotFoundException;
|
||||
import $package.application.exception.${entity}ExistException;
|
||||
import $package.presentation.dto.ErrorResponse;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class ${entity}ExceptionHandler {
|
||||
|
||||
@ExceptionHandler(${entity}NotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(${entity}NotFoundException ex, org.springframework.web.context.request.WebRequest request) {
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.NOT_FOUND.value(),
|
||||
"Not Found",
|
||||
ex.getMessage(),
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||
}
|
||||
|
||||
@ExceptionHandler(${entity}ExistException.class)
|
||||
public ResponseEntity<ErrorResponse> handleExist(${entity}ExistException ex, org.springframework.web.context.request.WebRequest request) {
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.CONFLICT.value(),
|
||||
"Conflict",
|
||||
ex.getMessage(),
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
public record ErrorResponse(
|
||||
int status,
|
||||
String error,
|
||||
String message,
|
||||
String path
|
||||
) { }
|
||||
@@ -0,0 +1,10 @@
|
||||
package $package.application.exception;
|
||||
|
||||
public class ${entity}ExistException extends RuntimeException {
|
||||
public ${entity}ExistException(String message) {
|
||||
super(message);
|
||||
}
|
||||
public ${entity}ExistException($id_type $id_name) {
|
||||
super("$entity already exists: " + $id_name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package $package.application.service;
|
||||
|
||||
$lombok_common_imports
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import $package.domain.service.${entity}Service;
|
||||
import $package.application.mapper.${entity}Mapper;
|
||||
import $package.application.exception.${entity}NotFoundException;
|
||||
import $package.presentation.dto.$EntityResponse;
|
||||
|
||||
@Service$service_annotations_block
|
||||
@Transactional(readOnly = true)
|
||||
public class Get${entity}Service {
|
||||
|
||||
private final ${entity}Service ${entity_lower}Service;
|
||||
private final ${entity}Mapper mapper;
|
||||
|
||||
$get_constructor
|
||||
|
||||
public $EntityResponse get($id_type $id_name) {
|
||||
return ${entity_lower}Service.findById($id_name)
|
||||
.map(mapper::toResponse)
|
||||
.orElseThrow(() -> new ${entity}NotFoundException($id_name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package $package.presentation.rest;
|
||||
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import $package.presentation.dto.ErrorResponse;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidationException(
|
||||
MethodArgumentNotValidException ex, org.springframework.web.context.request.WebRequest request) {
|
||||
String errors = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(f -> f.getField() + ": " + f.getDefaultMessage())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Validation Error",
|
||||
"Validation failed: " + errors,
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatusException(
|
||||
ResponseStatusException ex, org.springframework.web.context.request.WebRequest request) {
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
ex.getStatusCode().value(),
|
||||
ex.getStatusCode().toString(),
|
||||
ex.getReason(),
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return new ResponseEntity<>(error, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(
|
||||
DataIntegrityViolationException ex, org.springframework.web.context.request.WebRequest request) {
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.CONFLICT.value(),
|
||||
"Conflict",
|
||||
ex.getMostSpecificCause() != null ? ex.getMostSpecificCause().getMessage() : ex.getMessage(),
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package $package.infrastructure.persistence;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
$extra_imports
|
||||
$lombok_model_imports
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "$table_name")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class ${entity}Entity {
|
||||
|
||||
$jpa_fields_decls
|
||||
|
||||
protected ${entity}Entity() { /* for JPA */ }
|
||||
|
||||
// Full constructor (optional, can be removed if not needed)
|
||||
public ${entity}Entity($jpa_ctor_params) {
|
||||
$jpa_assigns
|
||||
}
|
||||
|
||||
// Lombok generates getters and setters automatically
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package $package.application.service;
|
||||
|
||||
$lombok_common_imports
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import $package.domain.service.${entity}Service;
|
||||
import $package.application.mapper.${entity}Mapper;
|
||||
import $package.presentation.dto.$EntityResponse;
|
||||
import $package.presentation.dto.PageResponse;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service$service_annotations_block
|
||||
@Transactional(readOnly = true)
|
||||
public class List${entity}Service {
|
||||
|
||||
private final ${entity}Service ${entity_lower}Service;
|
||||
private final ${entity}Mapper mapper;
|
||||
|
||||
$list_constructor
|
||||
|
||||
public PageResponse<$EntityResponse> list(int page, int size) {
|
||||
List<$EntityResponse> content = ${entity_lower}Service.findAll(page, size)
|
||||
.stream()
|
||||
.map(mapper::toResponse)
|
||||
.collect(Collectors.toList());
|
||||
long total = ${entity_lower}Service.count();
|
||||
int totalPages = (int) Math.ceil(total / (double) size);
|
||||
return new PageResponse<>(content, page, size, total, totalPages);
|
||||
}
|
||||
}
|
||||
16
skills/spring-boot-crud-patterns/templates/Mapper.java.tpl
Normal file
16
skills/spring-boot-crud-patterns/templates/Mapper.java.tpl
Normal file
@@ -0,0 +1,16 @@
|
||||
package $package.application.mapper;
|
||||
|
||||
import $package.domain.model.$entity;
|
||||
import $package.presentation.dto.$dto_request;
|
||||
import $package.presentation.dto.$dto_response;
|
||||
|
||||
public class ${entity}Mapper {
|
||||
|
||||
public $entity toAggregate($id_type id, $dto_request request) {
|
||||
return $entity.create($mapper_create_args);
|
||||
}
|
||||
|
||||
public $dto_response toResponse($entity a) {
|
||||
return new $dto_response($list_map_response_args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package $package.application.exception;
|
||||
|
||||
public class ${entity}NotFoundException extends RuntimeException {
|
||||
public ${entity}NotFoundException($id_type $id_name) {
|
||||
super("$entity not found: " + $id_name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
public record PageResponse<T>(
|
||||
java.util.List<T> content,
|
||||
int page,
|
||||
int size,
|
||||
long totalElements,
|
||||
int totalPages
|
||||
) { }
|
||||
@@ -0,0 +1,54 @@
|
||||
package $package.infrastructure.persistence;
|
||||
|
||||
$lombok_common_imports
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import $package.domain.model.$entity;
|
||||
import $package.domain.repository.${entity}Repository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component$adapter_annotations_block
|
||||
public class ${entity}RepositoryAdapter implements ${entity}Repository {
|
||||
|
||||
private final ${entity}JpaRepository jpa;
|
||||
|
||||
$adapter_constructor
|
||||
|
||||
@Override
|
||||
public $entity save($entity a) {
|
||||
${entity}Entity e = new ${entity}Entity($adapter_to_entity_args);
|
||||
e = jpa.save(e);
|
||||
return $entity.create($adapter_to_domain_args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<$entity> findById($id_type $id_name) {
|
||||
return jpa.findById($id_name).map(e -> $entity.create($adapter_to_domain_args));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<$entity> findAll(int page, int size) {
|
||||
return jpa.findAll(PageRequest.of(page, size))
|
||||
.stream()
|
||||
.map(e -> $entity.create($adapter_to_domain_args))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById($id_type $id_name) {
|
||||
jpa.deleteById($id_name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById($id_type $id_name) {
|
||||
return jpa.existsById($id_name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return jpa.count();
|
||||
}
|
||||
}
|
||||
39
skills/spring-boot-crud-patterns/templates/README.md
Normal file
39
skills/spring-boot-crud-patterns/templates/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# CRUD Generator Templates
|
||||
|
||||
You must provide all templates (.tpl) required by the generator; there is no fallback.
|
||||
|
||||
How it works:
|
||||
- The generator loads .tpl files from this directory (or a directory passed via --templates-dir).
|
||||
- It uses Python string.Template placeholders (e.g., $package, $entity, $id_type, $id_name, $id_name_lower, $base_path, $dto_request, $dto_response).
|
||||
- If any template is missing or fails to render, generation fails.
|
||||
|
||||
Required template filenames:
|
||||
- DomainModel.java.tpl
|
||||
- DomainRepository.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
|
||||
- Mapper.java.tpl
|
||||
- DtoRequest.java.tpl
|
||||
- DtoResponse.java.tpl
|
||||
- PageResponse.java.tpl
|
||||
- ErrorResponse.java.tpl
|
||||
- Controller.java.tpl
|
||||
- GlobalExceptionHandler.java.tpl
|
||||
- EntityExceptionHandler.java.tpl
|
||||
- NotFoundException.java.tpl
|
||||
- ExistException.java.tpl
|
||||
|
||||
Tip: Start simple and expand over time; these files are your team’s baseline.
|
||||
|
||||
Conventions:
|
||||
- Base path is versioned: /v1/{resources}
|
||||
- POST returns 201 Created and sets Location: /v1/{resources}/{id}
|
||||
- GET collection supports pagination via Pageable in controller and returns PageResponse<T>
|
||||
- Application layer uses ${Entity}Mapper for DTO↔Domain and throws ${Entity}ExistException on duplicates
|
||||
- Exceptions are mapped by GlobalExceptionHandler and ${Entity}ExceptionHandler
|
||||
@@ -0,0 +1,5 @@
|
||||
package $package.infrastructure.persistence;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ${entity}JpaRepository extends JpaRepository<${entity}Entity, $id_type> { }
|
||||
@@ -0,0 +1,32 @@
|
||||
package $package.application.service;
|
||||
|
||||
$lombok_common_imports
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import $package.domain.model.$entity;
|
||||
import $package.domain.service.${entity}Service;
|
||||
import $package.application.mapper.${entity}Mapper;
|
||||
import $package.application.exception.${entity}ExistException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import $package.presentation.dto.$EntityRequest;
|
||||
import $package.presentation.dto.$EntityResponse;
|
||||
|
||||
@Service$service_annotations_block
|
||||
@Transactional
|
||||
public class Update${entity}Service {
|
||||
|
||||
private final ${entity}Service ${entity_lower}Service;
|
||||
private final ${entity}Mapper mapper;
|
||||
|
||||
$update_constructor
|
||||
|
||||
public $EntityResponse update($id_type $id_name, $EntityRequest request) {
|
||||
try {
|
||||
$entity agg = mapper.toAggregate($id_name, request);
|
||||
agg = ${entity_lower}Service.save(agg);
|
||||
return mapper.toResponse(agg);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new ${entity}ExistException("Duplicate $entity");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user