From 7404bc120178d41752cad30496db59df4f867a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilija=20Spasi=C4=87?= Date: Sun, 12 Jul 2026 15:51:05 +0200 Subject: [PATCH 1/4] feature: improve generation of deployment section --- pse/bootstrap.py | 50 +- pse/generation_ownership.py | 112 +++ pse/generators/dotnet/capabilities.py | 11 + pse/generators/dotnet/deployment/__init__.py | 25 + pse/generators/dotnet/deployment/common.py | 104 +++ pse/generators/dotnet/deployment/compose.py | 35 + .../dotnet/deployment/dockerfile.py | 15 + .../dotnet/deployment/kubernetes.py | 281 ++++++++ pse/generators/dotnet/deployment/services.py | 73 ++ pse/generators/dotnet/deployment/swarm.py | 176 +++++ pse/generators/dotnet/docker.py | 110 --- pse/generators/dotnet/generator.py | 24 +- pse/generators/dotnet/git.py | 0 pse/generators/dotnet/integration.py | 88 +-- pse/generators/dotnet/nuget.py | 14 +- pse/generators/dotnet/program.py | 221 ++++++ pse/generators/dotnet/projects.py | 191 +---- pse/generators/dotnet/structure.py | 23 +- pse/generators/dotnet/structure_archetypes.py | 86 --- pse/generators/dotnet/structure_helpers.py | 34 +- pse/generators/dotnet/structure_sections.py | 166 +++-- pse/generators/dotnet/structure_writers.py | 652 +----------------- pse/generators/dotnet/template_loader.py | 17 +- pse/generators/dotnet/writers/__init__.py | 58 ++ pse/generators/dotnet/writers/api_support.py | 69 ++ .../dotnet/writers/controller_cqrs.py | 70 ++ .../dotnet/writers/controller_mapping.py | 32 + pse/generators/dotnet/writers/controllers.py | 152 ++++ pse/generators/dotnet/writers/cqrs.py | 38 + pse/generators/dotnet/writers/model.py | 202 ++++++ pse/generators/dotnet/writers/tests.py | 78 +++ pse/heuristics/archetypes.yaml | 7 +- pse/heuristics/packages.yaml | 4 +- pse/heuristics/versions.yaml | 2 + pse/model/dependency_graph.py | 9 +- pse/run.py | 7 +- pse/template_loader.py | 21 + .../deployment/kubernetes-readme.md.tmpl | 11 + pse/templates/deployment/swarm-readme.md.tmpl | 15 + pse/templates/dotnet/Aggregate.cs.tmpl | 14 + .../dotnet/AppSettings.Development.json.tmpl | 2 +- pse/templates/dotnet/AppSettings.json.tmpl | 2 +- .../dotnet/ControllerManualMapping.cs.tmpl | 14 + .../dotnet/ControllerMethods.cs.tmpl | 17 - pse/templates/dotnet/DbContext.cs.tmpl | 6 + pse/templates/dotnet/Dockerfile.tmpl | 24 +- .../dotnet/EfRepositoryImplementation.cs.tmpl | 54 ++ .../dotnet/MediatRControllerMethods.cs.tmpl | 59 ++ pse/templates/dotnet/OptionsClass.cs.tmpl | 5 - pse/templates/dotnet/Program.cs.tmpl | 11 + pse/templates/dotnet/Repository.cs.tmpl | 56 -- .../dotnet/RepositoryImplementation.cs.tmpl | 35 +- .../dotnet/ServiceControllerMethods.cs.tmpl | 56 ++ .../dotnet/WolverineControllerMethods.cs.tmpl | 59 ++ pse/templates/dotnet/docker-compose.yml.tmpl | 4 - pse/templates/editor/extension.js.tmpl | 123 ++++ pse/textx_generators.py | 1 + pse/validation.py | 118 ++++ pse/vscode.py | 149 +--- 59 files changed, 2575 insertions(+), 1517 deletions(-) create mode 100644 pse/generation_ownership.py create mode 100644 pse/generators/dotnet/capabilities.py create mode 100644 pse/generators/dotnet/deployment/__init__.py create mode 100644 pse/generators/dotnet/deployment/common.py create mode 100644 pse/generators/dotnet/deployment/compose.py create mode 100644 pse/generators/dotnet/deployment/dockerfile.py create mode 100644 pse/generators/dotnet/deployment/kubernetes.py create mode 100644 pse/generators/dotnet/deployment/services.py create mode 100644 pse/generators/dotnet/deployment/swarm.py delete mode 100644 pse/generators/dotnet/docker.py delete mode 100644 pse/generators/dotnet/git.py create mode 100644 pse/generators/dotnet/program.py delete mode 100644 pse/generators/dotnet/structure_archetypes.py create mode 100644 pse/generators/dotnet/writers/__init__.py create mode 100644 pse/generators/dotnet/writers/api_support.py create mode 100644 pse/generators/dotnet/writers/controller_cqrs.py create mode 100644 pse/generators/dotnet/writers/controller_mapping.py create mode 100644 pse/generators/dotnet/writers/controllers.py create mode 100644 pse/generators/dotnet/writers/cqrs.py create mode 100644 pse/generators/dotnet/writers/model.py create mode 100644 pse/generators/dotnet/writers/tests.py create mode 100644 pse/template_loader.py create mode 100644 pse/templates/deployment/kubernetes-readme.md.tmpl create mode 100644 pse/templates/deployment/swarm-readme.md.tmpl create mode 100644 pse/templates/dotnet/Aggregate.cs.tmpl create mode 100644 pse/templates/dotnet/ControllerManualMapping.cs.tmpl delete mode 100644 pse/templates/dotnet/ControllerMethods.cs.tmpl create mode 100644 pse/templates/dotnet/EfRepositoryImplementation.cs.tmpl create mode 100644 pse/templates/dotnet/MediatRControllerMethods.cs.tmpl delete mode 100644 pse/templates/dotnet/OptionsClass.cs.tmpl delete mode 100644 pse/templates/dotnet/Repository.cs.tmpl create mode 100644 pse/templates/dotnet/ServiceControllerMethods.cs.tmpl create mode 100644 pse/templates/dotnet/WolverineControllerMethods.cs.tmpl delete mode 100644 pse/templates/dotnet/docker-compose.yml.tmpl create mode 100644 pse/templates/editor/extension.js.tmpl diff --git a/pse/bootstrap.py b/pse/bootstrap.py index 547a855..a9636dd 100644 --- a/pse/bootstrap.py +++ b/pse/bootstrap.py @@ -1,8 +1,9 @@ import json import os import shutil +import tempfile import uuid -from datetime import datetime +from datetime import datetime, timezone from textx import metamodel_from_file @@ -18,7 +19,7 @@ PROJECT_ROOT = os.path.dirname(BASE_DIR) GRAMMAR_PATH = os.path.join(BASE_DIR, "grammar", "pse.tx") -def run_pse(input_file: str, output_dir: str): +def run_pse(input_file: str, output_dir: str, overwrite: bool = True): run_id = None try: print("PSE bootstrap starting...\n") @@ -53,7 +54,8 @@ def run_pse(input_file: str, output_dir: str): output_dir=output_dir, presets=heuristics["presets"], packages=heuristics["packages"], - versions=heuristics["versions"] + versions=heuristics["versions"], + options={"overwrite": overwrite}, ) # NEW: capability system @@ -121,7 +123,7 @@ def read_dsl_input(input_path: str): def write_run_manifest(output_dir: str, input_path: str, dsl_text: str, cap_graph=None): manifest_path = os.path.join(output_dir, "pse.manifest.json") run_id = str(uuid.uuid4()) - timestamp = datetime.utcnow().isoformat(timespec="seconds") + "Z" + timestamp = utc_timestamp() run_entry = { "id": run_id, @@ -140,13 +142,12 @@ def write_run_manifest(output_dir: str, input_path: str, dsl_text: str, cap_grap try: with open(manifest_path, "r", encoding="utf-8") as f: data = json.load(f) - except json.JSONDecodeError: - data = {"runs": []} + except json.JSONDecodeError as error: + raise ValueError(f"Run manifest is not valid JSON: {manifest_path}") from error data.setdefault("runs", []).append(run_entry) - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=True) + write_json_atomic(manifest_path, data) return run_id @@ -162,8 +163,8 @@ def update_run_status(output_dir: str, run_id: str, status: str, error: str = No try: with open(manifest_path, "r", encoding="utf-8") as f: data = json.load(f) - except json.JSONDecodeError: - return + except json.JSONDecodeError as error: + raise ValueError(f"Run manifest is not valid JSON: {manifest_path}") from error for run in data.get("runs", []): if run.get("id") == run_id: @@ -172,11 +173,10 @@ def update_run_status(output_dir: str, run_id: str, status: str, error: str = No run["error"] = error if cap_graph is not None: run["capabilities"] = serialize_capabilities(cap_graph) - run["finished_at"] = datetime.now().isoformat(timespec="seconds") + "Z" + run["finished_at"] = utc_timestamp() break - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=True) + write_json_atomic(manifest_path, data) def update_run_manifest(output_dir: str, run_id: str, status: str = None, error: str = None, cap_graph=None): @@ -190,8 +190,8 @@ def update_run_manifest(output_dir: str, run_id: str, status: str = None, error: try: with open(manifest_path, "r", encoding="utf-8") as f: data = json.load(f) - except json.JSONDecodeError: - return + except json.JSONDecodeError as error: + raise ValueError(f"Run manifest is not valid JSON: {manifest_path}") from error for run in data.get("runs", []): if run.get("id") == run_id: @@ -203,8 +203,24 @@ def update_run_manifest(output_dir: str, run_id: str, status: str = None, error: run["capabilities"] = serialize_capabilities(cap_graph) break - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=True) + write_json_atomic(manifest_path, data) + + +def utc_timestamp(): + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def write_json_atomic(path, data): + directory = os.path.dirname(os.path.abspath(path)) + fd, temporary_path = tempfile.mkstemp(prefix=".pse-manifest-", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=True) + handle.write("\n") + os.replace(temporary_path, path) + finally: + if os.path.exists(temporary_path): + os.remove(temporary_path) def serialize_capabilities(cap_graph): diff --git a/pse/generation_ownership.py b/pse/generation_ownership.py new file mode 100644 index 0000000..d940dd0 --- /dev/null +++ b/pse/generation_ownership.py @@ -0,0 +1,112 @@ +import hashlib +import json +import os +import shutil +import tempfile + + +MANIFEST_NAME = ".pse-generated.json" +IGNORED_PARTS = {"bin", "obj", ".git"} +IGNORED_FILES = {MANIFEST_NAME, "pse.manifest.json"} + + +def publish_generated_tree(staging_dir: str, output_dir: str, overwrite: bool): + os.makedirs(output_dir, exist_ok=True) + previous = load_manifest(output_dir) + generated = collect_files(staging_dir) + next_files = {} + + remove_stale_files(output_dir, previous, generated, overwrite) + + for relative_path, source_path in generated.items(): + destination = os.path.join(output_dir, relative_path) + previous_hash = previous.get(relative_path) + source_hash = file_hash(source_path) + + if should_replace(destination, previous_hash, source_hash, overwrite): + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copy2(source_path, destination) + next_files[relative_path] = source_hash + elif os.path.exists(destination) and file_hash(destination) == source_hash: + next_files[relative_path] = source_hash + + write_manifest(output_dir, next_files) + + +def collect_files(root): + files = {} + for current_root, directories, names in os.walk(root): + directories[:] = [name for name in directories if name not in IGNORED_PARTS] + for name in names: + if name in IGNORED_FILES: + continue + path = os.path.join(current_root, name) + relative_path = os.path.relpath(path, root) + files[relative_path] = path + return files + + +def should_replace(destination, previous_hash, source_hash, overwrite): + if not os.path.exists(destination): + return True + if overwrite: + return True + if previous_hash and file_hash(destination) == previous_hash: + return True + return file_hash(destination) == source_hash + + +def remove_stale_files(output_dir, previous, generated, overwrite): + for relative_path, previous_hash in previous.items(): + if relative_path in generated: + continue + path = os.path.join(output_dir, relative_path) + if not os.path.exists(path): + continue + if overwrite or file_hash(path) == previous_hash: + os.remove(path) + remove_empty_parents(os.path.dirname(path), output_dir) + + +def remove_empty_parents(path, boundary): + boundary = os.path.abspath(boundary) + current = os.path.abspath(path) + while current.startswith(boundary + os.sep) and current != boundary: + if os.listdir(current): + return + os.rmdir(current) + current = os.path.dirname(current) + + +def load_manifest(output_dir): + path = os.path.join(output_dir, MANIFEST_NAME) + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (json.JSONDecodeError, OSError): + return {} + return data.get("files", {}) + + +def write_manifest(output_dir, files): + path = os.path.join(output_dir, MANIFEST_NAME) + data = {"version": 1, "files": dict(sorted(files.items()))} + fd, temporary_path = tempfile.mkstemp(prefix=".pse-generated-", dir=output_dir) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=True) + handle.write("\n") + os.replace(temporary_path, path) + finally: + if os.path.exists(temporary_path): + os.remove(temporary_path) + + +def file_hash(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/pse/generators/dotnet/capabilities.py b/pse/generators/dotnet/capabilities.py new file mode 100644 index 0000000..b4f694b --- /dev/null +++ b/pse/generators/dotnet/capabilities.py @@ -0,0 +1,11 @@ +def capability_enabled(ctx, name: str): + return name.lower() in capability_registry(ctx) + + +def capability_value(ctx, name: str): + capability = capability_registry(ctx).get(name.lower()) + return getattr(capability, "value", None) + + +def capability_registry(ctx): + return getattr(getattr(ctx, "capabilities", None), "capabilities", {}) or {} diff --git a/pse/generators/dotnet/deployment/__init__.py b/pse/generators/dotnet/deployment/__init__.py new file mode 100644 index 0000000..46c24f1 --- /dev/null +++ b/pse/generators/dotnet/deployment/__init__.py @@ -0,0 +1,25 @@ +from .common import deployment_target +from .compose import create_compose_deployment +from .kubernetes import create_kubernetes_deployment +from .swarm import create_swarm_deployment + + +def create_deployment(ctx): + target = deployment_target(ctx) + + if target is None: + return + if target == "docker": + create_compose_deployment(ctx) + return + if target == "swarm": + create_swarm_deployment(ctx) + return + if target == "kubernetes": + create_kubernetes_deployment(ctx) + return + + raise ValueError(f"Unsupported deployment target: {target}") + + +__all__ = ["create_deployment"] diff --git a/pse/generators/dotnet/deployment/common.py b/pse/generators/dotnet/deployment/common.py new file mode 100644 index 0000000..a93dd1b --- /dev/null +++ b/pse/generators/dotnet/deployment/common.py @@ -0,0 +1,104 @@ +import os +import re +from dataclasses import dataclass + +import yaml + + +TARGET_ALIASES = { + "docker": "docker", + "dockercompose": "docker", + "compose": "docker", + "dockerswarm": "swarm", + "swarm": "swarm", + "kubernetes": "kubernetes", + "k8s": "kubernetes", +} + + +@dataclass(frozen=True) +class DeploymentSpec: + project_name: str + app_name: str + entrypoint_project: str + image: str + dotnet_version: str + postgres_version: str + redis_version: str + rabbitmq_version: str + has_database: bool + has_cache: bool + has_broker: bool + + @classmethod + def from_context(cls, ctx): + project = ctx.architecture.project + infra = ctx.architecture.infrastructure + app_name = resource_name(project.name) + entrypoint_layer = { + "WebApi": "API", + "CleanArchitecture": "Presentation", + }.get(project.archetype) + + if not entrypoint_layer: + raise ValueError( + f"Deployment generation is not supported for archetype '{project.archetype}' " + "because it has no generated web entrypoint." + ) + + return cls( + project_name=project.name, + app_name=app_name, + entrypoint_project=f"{project.name}.{entrypoint_layer}", + image=f"{app_name}:latest", + dotnet_version=str(ctx.versions.get("dotnet", "10.0")), + postgres_version=str(ctx.versions.get("postgres", "17")), + redis_version=str(ctx.versions.get("redis", "8")), + rabbitmq_version=str(ctx.versions.get("rabbitmq", "4-management")), + has_database=bool(infra and infra.database), + has_cache=bool(infra and infra.cache), + has_broker=bool(infra and infra.broker), + ) + + +def deployment_target(ctx): + deployment = getattr(ctx.architecture, "deployment", None) + raw_target = getattr(deployment, "target", None) + if not raw_target: + return None + return TARGET_ALIASES.get(normalize_target(raw_target), normalize_target(raw_target)) + + +def normalize_target(value): + return re.sub(r"[^a-z0-9]", "", str(value).lower()) + + +def resource_name(value): + normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return normalized or "pse-app" + + +def labels(spec, component): + return { + "app.kubernetes.io/name": spec.app_name, + "app.kubernetes.io/component": component, + "app.kubernetes.io/managed-by": "pse", + } + + +def write_yaml(path, document): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + yaml.safe_dump(document, handle, sort_keys=False, default_flow_style=False) + + +def write_yaml_documents(path, documents): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + yaml.safe_dump_all(documents, handle, sort_keys=False, default_flow_style=False) + + +def write_text(path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content if content.endswith("\n") else content + "\n") diff --git a/pse/generators/dotnet/deployment/compose.py b/pse/generators/dotnet/deployment/compose.py new file mode 100644 index 0000000..e8202bb --- /dev/null +++ b/pse/generators/dotnet/deployment/compose.py @@ -0,0 +1,35 @@ +import os + +from .common import DeploymentSpec, write_yaml +from .dockerfile import write_dockerfile +from .services import app_environment, compose_dependencies + + +def create_compose_deployment(ctx): + spec = DeploymentSpec.from_context(ctx) + write_dockerfile(ctx, spec) + + dependency_services, volumes = compose_dependencies(spec) + depends_on = { + name: {"condition": "service_healthy"} + for name in dependency_services + } + app = { + "build": {"context": ".", "dockerfile": "Dockerfile"}, + "image": spec.image, + "ports": ["8080:8080"], + "environment": app_environment(spec), + "networks": ["backend"], + "restart": "unless-stopped", + } + if depends_on: + app["depends_on"] = depends_on + + document = { + "services": {spec.app_name: app, **dependency_services}, + "networks": {"backend": {"driver": "bridge"}}, + } + if volumes: + document["volumes"] = volumes + + write_yaml(os.path.join(ctx.output_dir, "docker-compose.yml"), document) diff --git a/pse/generators/dotnet/deployment/dockerfile.py b/pse/generators/dotnet/deployment/dockerfile.py new file mode 100644 index 0000000..7fa5c19 --- /dev/null +++ b/pse/generators/dotnet/deployment/dockerfile.py @@ -0,0 +1,15 @@ +import os + +from ..template_loader import render_template +from .common import write_text + + +def write_dockerfile(ctx, spec): + content = render_template( + "Dockerfile.tmpl", + { + "DotnetVersion": spec.dotnet_version, + "EntrypointProject": spec.entrypoint_project, + }, + ) + write_text(os.path.join(ctx.output_dir, "Dockerfile"), content) diff --git a/pse/generators/dotnet/deployment/kubernetes.py b/pse/generators/dotnet/deployment/kubernetes.py new file mode 100644 index 0000000..db438df --- /dev/null +++ b/pse/generators/dotnet/deployment/kubernetes.py @@ -0,0 +1,281 @@ +import os + +from pse.template_loader import render_template + +from .common import DeploymentSpec, labels, write_text, write_yaml_documents +from .dockerfile import write_dockerfile + + +def create_kubernetes_deployment(ctx): + spec = DeploymentSpec.from_context(ctx) + write_dockerfile(ctx, spec) + documents = [app_deployment(spec), app_service(spec)] + + if spec.has_database: + documents.extend([postgres_service(spec), postgres_stateful_set(spec)]) + if spec.has_cache: + documents.extend([redis_service(spec), redis_stateful_set(spec)]) + if spec.has_broker: + documents.extend([rabbitmq_service(spec), rabbitmq_stateful_set(spec)]) + + deploy_dir = os.path.join(ctx.output_dir, "deploy", "kubernetes") + write_yaml_documents(os.path.join(deploy_dir, "namespace.yaml"), [namespace(spec)]) + if spec.has_database or spec.has_cache or spec.has_broker: + write_yaml_documents(os.path.join(deploy_dir, "secret.example.yaml"), [app_secret(spec)]) + write_yaml_documents(os.path.join(deploy_dir, "manifest.yaml"), documents) + write_text(os.path.join(deploy_dir, "README.md"), kubernetes_readme(spec)) + + +def namespace(spec): + return { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": {"name": spec.app_name, "labels": labels(spec, "platform")}, + } + + +def app_secret(spec): + string_data = {} + if spec.has_database: + string_data["database-connection"] = "Host=postgres;Port=5432;Database=app;Username=postgres;Password=change-me" + string_data["postgres-password"] = "change-me" + if spec.has_cache: + string_data["redis-connection"] = "redis:6379" + if spec.has_broker: + string_data["rabbitmq-connection"] = "amqp://app:change-me@rabbitmq:5672" + string_data["rabbitmq-password"] = "change-me" + return { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": f"{spec.app_name}-secrets", "namespace": spec.app_name, "labels": labels(spec, "configuration")}, + "type": "Opaque", + "stringData": string_data, + } + + +def app_deployment(spec): + pod_labels = labels(spec, "api") + env = [ + {"name": "ASPNETCORE_ENVIRONMENT", "value": "Production"}, + {"name": "ASPNETCORE_URLS", "value": "http://+:8080"}, + ] + for enabled, env_name, key in ( + (spec.has_database, "ConnectionStrings__Database", "database-connection"), + (spec.has_cache, "ConnectionStrings__Redis", "redis-connection"), + (spec.has_broker, "ConnectionStrings__RabbitMq", "rabbitmq-connection"), + ): + if enabled: + env.append(secret_env(spec, env_name, key)) + + return { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": {"name": spec.app_name, "namespace": spec.app_name, "labels": pod_labels}, + "spec": { + "replicas": 2, + "strategy": {"type": "RollingUpdate", "rollingUpdate": {"maxUnavailable": 0, "maxSurge": 1}}, + "selector": {"matchLabels": {"app.kubernetes.io/name": spec.app_name, "app.kubernetes.io/component": "api"}}, + "template": { + "metadata": {"labels": pod_labels}, + "spec": { + "securityContext": {"runAsNonRoot": True, "seccompProfile": {"type": "RuntimeDefault"}}, + "containers": [{ + "name": "api", + "image": spec.image, + "imagePullPolicy": "IfNotPresent", + "ports": [{"name": "http", "containerPort": 8080}], + "env": env, + "readinessProbe": http_probe("/health/ready", 5), + "livenessProbe": http_probe("/health/live", 15), + "resources": { + "requests": {"cpu": "100m", "memory": "128Mi"}, + "limits": {"cpu": "1000m", "memory": "512Mi"}, + }, + "securityContext": { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "capabilities": {"drop": ["ALL"]}, + }, + "volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}], + }], + "volumes": [{"name": "tmp", "emptyDir": {}}], + }, + }, + }, + } + + +def app_service(spec): + return service_document(spec, spec.app_name, "api", 80, 8080) + + +def postgres_service(spec): + return service_document(spec, "postgres", "postgres", 5432, 5432, headless=True) + + +def redis_service(spec): + return service_document(spec, "redis", "redis", 6379, 6379, headless=True) + + +def rabbitmq_service(spec): + return service_document(spec, "rabbitmq", "rabbitmq", 5672, 5672, headless=True) + + +def service_document(spec, name, component, port, target_port, headless=False): + document = { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": name, "namespace": spec.app_name, "labels": labels(spec, component)}, + "spec": { + "selector": {"app.kubernetes.io/name": spec.app_name, "app.kubernetes.io/component": component}, + "ports": [{"name": "tcp", "port": port, "targetPort": target_port}], + }, + } + if headless: + document["spec"]["clusterIP"] = "None" + return document + + +def postgres_stateful_set(spec): + return stateful_set( + spec, + "postgres", + f"postgres:{spec.postgres_version}", + 5432, + [ + {"name": "POSTGRES_DB", "value": "app"}, + {"name": "POSTGRES_USER", "value": "postgres"}, + {"name": "PGDATA", "value": "/var/lib/postgresql/data/pgdata"}, + secret_env(spec, "POSTGRES_PASSWORD", "postgres-password"), + ], + ["CMD-SHELL", "pg_isready -U postgres -d app"], + "/var/lib/postgresql/data", + ) + + +def redis_stateful_set(spec): + return stateful_set( + spec, + "redis", + f"redis:{spec.redis_version}", + 6379, + [], + ["CMD", "redis-cli", "ping"], + "/data", + command=["redis-server", "--appendonly", "yes"], + ) + + +def rabbitmq_stateful_set(spec): + return stateful_set( + spec, + "rabbitmq", + f"rabbitmq:{spec.rabbitmq_version}", + 5672, + [ + {"name": "RABBITMQ_DEFAULT_USER", "value": "app"}, + secret_env(spec, "RABBITMQ_DEFAULT_PASS", "rabbitmq-password"), + ], + ["CMD", "rabbitmq-diagnostics", "-q", "ping"], + "/var/lib/rabbitmq", + ) + + +def stateful_set(spec, name, image, port, env, readiness_command, mount_path, command=None): + pod_labels = labels(spec, name) + container = { + "name": name, + "image": image, + "ports": [{"name": "tcp", "containerPort": port}], + "env": env, + "readinessProbe": { + "exec": {"command": readiness_command}, + "initialDelaySeconds": 5, + "periodSeconds": 10, + }, + "resources": { + "requests": {"cpu": "100m", "memory": "128Mi"}, + "limits": {"cpu": "500m", "memory": "512Mi"}, + }, + "securityContext": {"allowPrivilegeEscalation": False, "capabilities": {"drop": ["ALL"]}}, + "volumeMounts": [{"name": "data", "mountPath": mount_path}], + } + if command: + container["command"] = command + + return { + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": {"name": name, "namespace": spec.app_name, "labels": pod_labels}, + "spec": { + "serviceName": name, + "replicas": 1, + "selector": {"matchLabels": {"app.kubernetes.io/name": spec.app_name, "app.kubernetes.io/component": name}}, + "template": { + "metadata": {"labels": pod_labels}, + "spec": {"containers": [container]}, + }, + "volumeClaimTemplates": [{ + "metadata": {"name": "data"}, + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + }, + }], + }, + } + + +def secret_env(spec, name, key): + return { + "name": name, + "valueFrom": {"secretKeyRef": {"name": f"{spec.app_name}-secrets", "key": key}}, + } + + +def http_probe(path, initial_delay): + return { + "httpGet": {"path": path, "port": "http"}, + "initialDelaySeconds": initial_delay, + "periodSeconds": 10, + "timeoutSeconds": 3, + "failureThreshold": 3, + } + + +def kubernetes_readme(spec): + secret_setup = "" + if spec.has_database or spec.has_cache or spec.has_broker: + secret_setup = ( + f"\n{secret_command(spec)}\n" + ) + + return render_template( + "deployment/kubernetes-readme.md.tmpl", + { + "Image": spec.image, + "SecretSetup": secret_setup, + "AppName": spec.app_name, + }, + ) + + +def secret_command(spec): + literals = [] + if spec.has_database: + literals.extend([ + "--from-literal=database-connection='replace-me'", + "--from-literal=postgres-password='replace-me'", + ]) + if spec.has_cache: + literals.append("--from-literal=redis-connection='redis:6379'") + if spec.has_broker: + literals.extend([ + "--from-literal=rabbitmq-connection='replace-me'", + "--from-literal=rabbitmq-password='replace-me'", + ]) + + return ( + f"kubectl -n {spec.app_name} create secret generic {spec.app_name}-secrets \\\n" + + " \\\n".join(f" {literal}" for literal in literals) + ) diff --git a/pse/generators/dotnet/deployment/services.py b/pse/generators/dotnet/deployment/services.py new file mode 100644 index 0000000..a3f88aa --- /dev/null +++ b/pse/generators/dotnet/deployment/services.py @@ -0,0 +1,73 @@ +def app_environment(spec): + environment = { + "ASPNETCORE_ENVIRONMENT": "Production", + "ASPNETCORE_URLS": "http://+:8080", + } + if spec.has_database: + environment["ConnectionStrings__Database"] = ( + "Host=postgres;Port=5432;Database=app;Username=postgres;Password=postgres" + ) + if spec.has_cache: + environment["ConnectionStrings__Redis"] = "redis:6379" + if spec.has_broker: + environment["ConnectionStrings__RabbitMq"] = "amqp://app:rabbitmq@rabbitmq:5672" + return environment + + +def compose_dependencies(spec): + services = {} + volumes = {} + + if spec.has_database: + services["postgres"] = { + "image": f"postgres:{spec.postgres_version}", + "environment": { + "POSTGRES_DB": "app", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": "postgres", + }, + "healthcheck": { + "test": ["CMD-SHELL", "pg_isready -U postgres -d app"], + "interval": "10s", + "timeout": "5s", + "retries": 5, + }, + "volumes": ["postgres-data:/var/lib/postgresql/data"], + "networks": ["backend"], + } + volumes["postgres-data"] = {} + + if spec.has_cache: + services["redis"] = { + "image": f"redis:{spec.redis_version}", + "command": ["redis-server", "--appendonly", "yes"], + "healthcheck": { + "test": ["CMD", "redis-cli", "ping"], + "interval": "10s", + "timeout": "5s", + "retries": 5, + }, + "volumes": ["redis-data:/data"], + "networks": ["backend"], + } + volumes["redis-data"] = {} + + if spec.has_broker: + services["rabbitmq"] = { + "image": f"rabbitmq:{spec.rabbitmq_version}", + "environment": { + "RABBITMQ_DEFAULT_USER": "app", + "RABBITMQ_DEFAULT_PASS": "rabbitmq", + }, + "healthcheck": { + "test": ["CMD", "rabbitmq-diagnostics", "-q", "ping"], + "interval": "10s", + "timeout": "5s", + "retries": 5, + }, + "volumes": ["rabbitmq-data:/var/lib/rabbitmq"], + "networks": ["backend"], + } + volumes["rabbitmq-data"] = {} + + return services, volumes diff --git a/pse/generators/dotnet/deployment/swarm.py b/pse/generators/dotnet/deployment/swarm.py new file mode 100644 index 0000000..9d238c9 --- /dev/null +++ b/pse/generators/dotnet/deployment/swarm.py @@ -0,0 +1,176 @@ +import os + +from pse.template_loader import render_template + +from .common import DeploymentSpec, write_text, write_yaml +from .dockerfile import write_dockerfile + + +def create_swarm_deployment(ctx): + spec = DeploymentSpec.from_context(ctx) + write_dockerfile(ctx, spec) + + services = {spec.app_name: app_service(spec)} + volumes = {} + secrets = {} + + if spec.has_database: + services["postgres"] = postgres_service(spec) + volumes["postgres-data"] = {} + secrets["postgres-password"] = {"external": True} + secrets["database-connection"] = {"external": True} + if spec.has_cache: + services["redis"] = redis_service(spec) + volumes["redis-data"] = {} + secrets["redis-connection"] = {"external": True} + if spec.has_broker: + services["rabbitmq"] = rabbitmq_service(spec) + volumes["rabbitmq-data"] = {} + secrets["rabbitmq-connection"] = {"external": True} + secrets["rabbitmq-password"] = {"external": True} + + document = { + "services": services, + "networks": {"backend": {"driver": "overlay", "attachable": True}}, + } + if volumes: + document["volumes"] = volumes + if secrets: + document["secrets"] = secrets + + deploy_dir = os.path.join(ctx.output_dir, "deploy", "swarm") + write_yaml(os.path.join(deploy_dir, "stack.yml"), document) + write_text(os.path.join(deploy_dir, "README.md"), swarm_readme(spec)) + + +def app_service(spec): + service = { + "image": spec.image, + "environment": { + "ASPNETCORE_ENVIRONMENT": "Production", + "ASPNETCORE_URLS": "http://+:8080", + }, + "ports": [{"target": 8080, "published": 8080, "protocol": "tcp", "mode": "ingress"}], + "networks": ["backend"], + "deploy": { + "replicas": 2, + "update_config": {"parallelism": 1, "delay": "10s", "order": "start-first"}, + "rollback_config": {"parallelism": 1, "order": "stop-first"}, + "restart_policy": {"condition": "on-failure", "delay": "5s", "max_attempts": 3}, + "resources": { + "limits": {"cpus": "1.0", "memory": "512M"}, + "reservations": {"cpus": "0.25", "memory": "128M"}, + }, + }, + "healthcheck": { + "test": ["CMD", "curl", "--fail", "--silent", "http://localhost:8080/health/live"], + "interval": "30s", + "timeout": "5s", + "retries": 3, + "start_period": "15s", + }, + } + secret_names = [] + if spec.has_database: + secret_names.append("database-connection") + if spec.has_cache: + secret_names.append("redis-connection") + if spec.has_broker: + secret_names.append("rabbitmq-connection") + if secret_names: + service["secrets"] = [ + {"source": name, "target": secret_target(name)} + for name in secret_names + ] + return service + + +def postgres_service(spec): + return { + "image": f"postgres:{spec.postgres_version}", + "environment": { + "POSTGRES_DB": "app", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD_FILE": "/run/secrets/postgres-password", + }, + "secrets": ["postgres-password"], + "volumes": ["postgres-data:/var/lib/postgresql/data"], + "networks": ["backend"], + "healthcheck": { + "test": ["CMD-SHELL", "pg_isready -U postgres -d app"], + "interval": "10s", + "timeout": "5s", + "retries": 5, + }, + "deploy": {"replicas": 1, "restart_policy": {"condition": "on-failure"}}, + } + + +def redis_service(spec): + return { + "image": f"redis:{spec.redis_version}", + "command": ["redis-server", "--appendonly", "yes"], + "volumes": ["redis-data:/data"], + "networks": ["backend"], + "healthcheck": { + "test": ["CMD", "redis-cli", "ping"], + "interval": "10s", + "timeout": "5s", + "retries": 5, + }, + "deploy": {"replicas": 1, "restart_policy": {"condition": "on-failure"}}, + } + + +def rabbitmq_service(spec): + return { + "image": f"rabbitmq:{spec.rabbitmq_version}", + "environment": { + "RABBITMQ_DEFAULT_USER": "app", + "RABBITMQ_DEFAULT_PASS_FILE": "/run/secrets/rabbitmq-password", + }, + "secrets": ["rabbitmq-password"], + "volumes": ["rabbitmq-data:/var/lib/rabbitmq"], + "networks": ["backend"], + "healthcheck": { + "test": ["CMD", "rabbitmq-diagnostics", "-q", "ping"], + "interval": "10s", + "timeout": "5s", + "retries": 5, + }, + "deploy": {"replicas": 1, "restart_policy": {"condition": "on-failure"}}, + } + + +def secret_target(name): + return { + "database-connection": "ConnectionStrings__Database", + "redis-connection": "ConnectionStrings__Redis", + "rabbitmq-connection": "ConnectionStrings__RabbitMq", + }[name] + + +def swarm_readme(spec): + commands = [] + if spec.has_database: + commands.extend([ + "printf '%s' 'replace-me' | docker secret create postgres-password -", + "printf '%s' 'Host=postgres;Port=5432;Database=app;Username=postgres;Password=replace-me' | docker secret create database-connection -", + ]) + if spec.has_cache: + commands.append("printf '%s' 'redis:6379' | docker secret create redis-connection -") + if spec.has_broker: + commands.extend([ + "printf '%s' 'replace-me' | docker secret create rabbitmq-password -", + "printf '%s' 'amqp://app:replace-me@rabbitmq:5672' | docker secret create rabbitmq-connection -", + ]) + + secret_block = "\n".join(commands) or "# No infrastructure secrets are required." + return render_template( + "deployment/swarm-readme.md.tmpl", + { + "Image": spec.image, + "SecretCommands": secret_block, + "AppName": spec.app_name, + }, + ) diff --git a/pse/generators/dotnet/docker.py b/pse/generators/dotnet/docker.py deleted file mode 100644 index 23c6a9c..0000000 --- a/pse/generators/dotnet/docker.py +++ /dev/null @@ -1,110 +0,0 @@ -import os - -from .template_loader import render_template - - -def create_docker(ctx): - - if ctx.architecture.deployment.target != "Docker": - return - - write_dockerfile(ctx) - write_compose(ctx) - - -def write_dockerfile(ctx): - name = ctx.architecture.project.name - version = ctx.versions.get("dotnet", "9.0") - - content = render_template( - "Dockerfile.tmpl", - { - "DotnetVersion": version, - "ProjectName": name, - } - ) - - path = os.path.join(ctx.output_dir, "Dockerfile") - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_compose(ctx): - name = ctx.architecture.project.name - infra = ctx.architecture.infrastructure - - services = [ - f" {name.lower()}:", - " build: .", - f" container_name: {name.lower()}", - " ports:", - " - \"8080:8080\"", - ] - - depends_on = [] - extras = [] - - if infra and infra.database and infra.database.type: - db_type = infra.database.type.lower() - if db_type == "postgresql" or db_type == "postgres": - depends_on.append("db") - extras.extend([ - " db:", - f" image: postgres:{ctx.versions.get('postgres', '17')}", - " container_name: postgres", - " environment:", - " POSTGRES_USER: postgres", - " POSTGRES_PASSWORD: postgres", - " POSTGRES_DB: app", - " ports:", - " - \"5432:5432\"", - ]) - - if infra and infra.cache and infra.cache.type: - cache_type = infra.cache.type.lower() - if cache_type == "redis": - depends_on.append("redis") - extras.extend([ - " redis:", - f" image: redis:{ctx.versions.get('redis', '8')}", - " container_name: redis", - " ports:", - " - \"6379:6379\"", - ]) - - if infra and infra.broker and infra.broker.type: - broker_type = infra.broker.type.lower() - if broker_type == "rabbitmq": - depends_on.append("rabbitmq") - extras.extend([ - " rabbitmq:", - f" image: rabbitmq:{ctx.versions.get('rabbitmq', '4')}", - " container_name: rabbitmq", - " ports:", - " - \"5672:5672\"", - " - \"15672:15672\"", - ]) - - if depends_on: - services.append(" depends_on:") - services.extend([f" - {dep}" for dep in depends_on]) - - services_block = "\n".join(services) - dependencies_block = "" - - if extras: - dependencies_block = "\n".join([" # dependencies", *extras]) - - content = render_template( - "docker-compose.yml.tmpl", - { - "Services": services_block, - "Dependencies": dependencies_block, - } - ) - - path = os.path.join(ctx.output_dir, "docker-compose.yml") - - with open(path, "w", encoding="utf-8") as f: - f.write(content if content.endswith("\n") else content + "\n") \ No newline at end of file diff --git a/pse/generators/dotnet/generator.py b/pse/generators/dotnet/generator.py index 2256d68..823ee8a 100644 --- a/pse/generators/dotnet/generator.py +++ b/pse/generators/dotnet/generator.py @@ -1,13 +1,27 @@ +import copy +import tempfile + +from pse.generation_ownership import publish_generated_tree + from .solution import create_solution from .projects import create_projects from .structure import create_structure from .integration import create_integration -from .nuget import restore_packages -from .docker import create_docker +from .nuget import configure_packages +from .deployment import create_deployment from .cleanup import clean_solution def generate_dotnet(ctx): + overwrite = ctx.options.get("overwrite", True) + with tempfile.TemporaryDirectory(prefix="pse-dotnet-") as staging_dir: + staging_ctx = copy.copy(ctx) + staging_ctx.output_dir = staging_dir + generate_dotnet_staging(staging_ctx) + publish_generated_tree(staging_dir, ctx.output_dir, overwrite) + + +def generate_dotnet_staging(ctx): ctx.log("🚀 Generating .NET solution...") @@ -15,8 +29,8 @@ def generate_dotnet(ctx): create_projects(ctx) create_structure(ctx) create_integration(ctx) - restore_packages(ctx) - create_docker(ctx) + configure_packages(ctx) + create_deployment(ctx) clean_solution(ctx) - ctx.log("✅ .NET generation complete") \ No newline at end of file + ctx.log("✅ .NET generation complete") diff --git a/pse/generators/dotnet/git.py b/pse/generators/dotnet/git.py deleted file mode 100644 index e69de29..0000000 diff --git a/pse/generators/dotnet/integration.py b/pse/generators/dotnet/integration.py index 09023d0..cec6b05 100644 --- a/pse/generators/dotnet/integration.py +++ b/pse/generators/dotnet/integration.py @@ -1,11 +1,11 @@ import os +from .structure_helpers import pick_id_property_name from .template_loader import render_template def create_integration(ctx): write_appsettings(ctx) - write_options(ctx) write_db_context(ctx) @@ -21,35 +21,16 @@ def write_appsettings(ctx): ] connection_lines = [] - section_lines = [] - if infra and infra.database: connection_lines.append(" \"Database\": \"Host=localhost;Port=5432;Database=app;Username=postgres;Password=postgres\"") - section_lines.extend([ - " \"Database\": {", - " \"Provider\": \"Postgres\"", - " },", - ]) if infra and infra.cache: connection_lines.append(" \"Redis\": \"localhost:6379\"") - section_lines.extend([ - " \"Redis\": {", - " \"Enabled\": true", - " },", - ]) if infra and infra.broker: - connection_lines.append(" \"RabbitMq\": \"amqp://guest:guest@localhost:5672\"") - section_lines.extend([ - " \"RabbitMq\": {", - " \"Enabled\": true", - " },", - ]) + connection_lines.append(" \"RabbitMq\": \"amqp://app:rabbitmq@localhost:5672\"") connection_block = ",\n".join(connection_lines) - section_block = "\n".join(section_lines) - connection_strings_block = "" if connection_block: connection_strings_block = ( @@ -58,14 +39,10 @@ def write_appsettings(ctx): + "\n },\n" ) - if section_block: - section_block = section_block.rstrip(",") + ",\n" - content = render_template( "AppSettings.json.tmpl", { "ConnectionStringsBlock": connection_strings_block, - "InfraSections": section_block, } ) @@ -73,7 +50,6 @@ def write_appsettings(ctx): "AppSettings.Development.json.tmpl", { "ConnectionStringsBlock": connection_strings_block, - "InfraSections": section_block, } ) @@ -85,37 +61,6 @@ def write_appsettings(ctx): write_file(os.path.join(path, "appsettings.Development.json"), dev_content) -def write_options(ctx): - output_dir = os.path.abspath(ctx.output_dir) - base = ctx.architecture.project.name - infra = ctx.architecture.infrastructure - - app_root = os.path.join(output_dir, f"{base}.Application") - if not os.path.isdir(app_root): - return - - options_root = os.path.join(app_root, "Options") - os.makedirs(options_root, exist_ok=True) - - namespace = f"{base}.Application.Options" - - if infra and infra.database: - body = " public string ConnectionString { get; set; } = string.Empty;\n" - write_options_class(options_root, namespace, "DatabaseOptions", body) - - if infra and infra.cache: - body = " public string ConnectionString { get; set; } = string.Empty;\n" - write_options_class(options_root, namespace, "RedisOptions", body) - - if infra and infra.broker: - body = ( - " public string Host { get; set; } = string.Empty;\n" - " public string Username { get; set; } = \"guest\";\n" - " public string Password { get; set; } = \"guest\";\n" - ) - write_options_class(options_root, namespace, "RabbitMqOptions", body) - - def write_db_context(ctx): output_dir = os.path.abspath(ctx.output_dir) base = ctx.architecture.project.name @@ -132,30 +77,33 @@ def write_db_context(ctx): os.makedirs(persistence_root, exist_ok=True) namespace = f"{base}.Infrastructure.Persistence" + entities = [ + entity + for context in ctx.architecture.contexts or [] + for entity in context.entities + ] + db_sets = "".join( + f" public DbSet<{entity.name}> {entity.name}Set => Set<{entity.name}>();\n" + for entity in entities + ) + key_configurations = "".join( + f" modelBuilder.Entity<{entity.name}>().HasKey(entity => entity.{pick_id_property_name(entity.properties)});\n" + for entity in entities + ) content = render_template( "DbContext.cs.tmpl", { "Namespace": namespace, "ClassName": "AppDbContext", + "DomainNamespace": f"{base}.Domain", + "DbSets": db_sets, + "KeyConfigurations": key_configurations, } ) write_file(os.path.join(persistence_root, "AppDbContext.cs"), content) -def write_options_class(options_root: str, namespace: str, class_name: str, body: str): - content = render_template( - "OptionsClass.cs.tmpl", - { - "Namespace": namespace, - "ClassName": class_name, - "Body": body, - } - ) - - write_file(os.path.join(options_root, f"{class_name}.cs"), content) - - def write_file(path: str, content: str): with open(path, "w", encoding="utf-8") as f: f.write(content if content.endswith("\n") else content + "\n") diff --git a/pse/generators/dotnet/nuget.py b/pse/generators/dotnet/nuget.py index 032073b..9672300 100644 --- a/pse/generators/dotnet/nuget.py +++ b/pse/generators/dotnet/nuget.py @@ -42,13 +42,21 @@ def first_existing_project(ctx, layers): return None -def restore_packages(ctx): +def configure_packages(ctx): packages = resolve_packages(ctx) project_map = capability_project_map() default_project = project_path(ctx, "Application") - for capability, implementation in packages.items(): + capability_order = list(packages) + dependency_graph = getattr(ctx, "dependency_graph", None) + if dependency_graph: + ordered = dependency_graph.topological_sort() + capability_order = [name for name in ordered if name in packages] + capability_order.extend(name for name in packages if name not in capability_order) + + for capability in capability_order: + implementation = packages[capability] if implementation == "unresolved": continue @@ -67,13 +75,13 @@ def restore_packages(ctx): target_project, "package", package_name, + "--no-restore", ] if package_version: command.extend(["--version", package_version]) run_dotnet(command, cwd=ctx.output_dir) - def package_target_projects(ctx, capability: str, project_map): if capability in {"logging", "validation", "mapping"}: project = first_existing_project(ctx, ["API", "Presentation", "Gateway"]) diff --git a/pse/generators/dotnet/program.py b/pse/generators/dotnet/program.py new file mode 100644 index 0000000..58343b5 --- /dev/null +++ b/pse/generators/dotnet/program.py @@ -0,0 +1,221 @@ +from .capabilities import capability_enabled, capability_value + + +def build_program_values(ctx, layer: str): + infra = ctx.architecture.infrastructure + base = ctx.architecture.project.name + contexts = ctx.architecture.contexts + + using_lines = [] + host_configuration = [] + service_registrations = [] + registrations = [] + health_checks = [ + "builder.Services.AddHealthChecks()", + " .AddCheck(\"self\", () => HealthCheckResult.Healthy(), tags: new[] { \"live\" })", + ] + using_lines.extend([ + "using Microsoft.AspNetCore.Diagnostics.HealthChecks;\n", + "using Microsoft.Extensions.Diagnostics.HealthChecks;\n", + ]) + + entities = [ + entity + for context in contexts or [] + for entity in getattr(context, "entities", []) or [] + ] + + add_capability_registrations( + ctx, + layer, + base, + entities, + using_lines, + host_configuration, + service_registrations, + ) + add_application_registrations(base, entities, using_lines, service_registrations) + add_infrastructure_registrations( + infra, + base, + entities, + using_lines, + service_registrations, + registrations, + health_checks, + ) + + return { + "UsingLines": render_block(using_lines, joiner=""), + "HostConfiguration": render_block(host_configuration), + "ServiceRegistrations": render_block(service_registrations), + "InfraRegistrations": render_block(registrations), + "InfraPipeline": build_infrastructure_pipeline(infra), + "HealthChecks": render_health_checks(health_checks), + } + + +def add_capability_registrations( + ctx, + layer, + base, + entities, + using_lines, + host_configuration, + service_registrations, +): + if capability_enabled(ctx, "logging"): + using_lines.append("using Serilog;\n") + host_configuration.extend([ + "builder.Host.UseSerilog((context, services, loggerConfiguration) =>", + " loggerConfiguration", + " .ReadFrom.Configuration(context.Configuration)", + " .ReadFrom.Services(services)", + " .WriteTo.Console());", + "", + ]) + + if capability_enabled(ctx, "validation"): + using_lines.extend(["using FluentValidation;\n", "using FluentValidation.AspNetCore;\n"]) + service_registrations.extend([ + "builder.Services.AddFluentValidationAutoValidation();", + "builder.Services.AddValidatorsFromAssemblyContaining();", + "", + ]) + + if capability_enabled(ctx, "mapping"): + using_lines.extend([ + "using Mapster;\n", + "using MapsterMapper;\n", + f"using {base}.{layer}.Mapping;\n", + ]) + service_registrations.extend([ + "MappingConfig.Register();", + "builder.Services.AddMapster();", + "", + ]) + + add_cqrs_registrations( + capability_value(ctx, "cqrs"), + base, + entities, + using_lines, + host_configuration, + service_registrations, + ) + + +def add_cqrs_registrations( + implementation, + base, + entities, + using_lines, + host_configuration, + service_registrations, +): + first_entity = entities[0] if entities else None + if implementation == "mediatr" and first_entity: + using_lines.append(f"using {base}.Application.Cqrs;\n") + service_registrations.extend([ + f"builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining());", + "", + ]) + elif implementation == "wolverine": + using_lines.append("using Wolverine;\n") + if first_entity: + using_lines.append(f"using {base}.Application.Cqrs;\n") + host_configuration.extend([ + "builder.Host.UseWolverine(options =>", + "{", + f" options.Discovery.IncludeAssembly(typeof(GetAll{first_entity.name}Query).Assembly);", + "});", + "", + ]) + else: + host_configuration.extend(["builder.Host.UseWolverine();", ""]) + + +def add_application_registrations(base, entities, using_lines, registrations): + if not entities: + return + + using_lines.extend([ + f"using {base}.Application.Interfaces;\n", + f"using {base}.Application.Services;\n", + ]) + registrations.extend( + f"builder.Services.AddScoped();" + for entity in entities + ) + registrations.append("") + + +def add_infrastructure_registrations(infra, base, entities, using_lines, services, registrations, health_checks): + if entities: + using_lines.extend([ + f"using {base}.Domain.Repositories;\n", + f"using {base}.Infrastructure.Repositories;\n", + ]) + repository_lifetime = "Scoped" if infra and infra.database else "Singleton" + for entity in entities: + services.append( + f"builder.Services.Add{repository_lifetime}();" + ) + services.append("") + + if infra and infra.database: + using_lines.extend([ + "using Microsoft.EntityFrameworkCore;\n", + f"using {base}.Infrastructure.Persistence;\n", + ]) + registrations.extend([ + "builder.Services.AddDbContext(options =>", + " options.UseNpgsql(builder.Configuration.GetConnectionString(\"Database\")));", + "", + ]) + health_checks.append( + " .AddDbContextCheck(\"database\", tags: new[] { \"ready\" })" + ) + + if infra and infra.cache: + registrations.extend([ + "builder.Services.AddStackExchangeRedisCache(options =>", + " options.Configuration = builder.Configuration.GetConnectionString(\"Redis\"));", + "", + ]) + + if infra and infra.broker: + using_lines.append("using MassTransit;\n") + registrations.extend([ + "builder.Services.AddMassTransit(x =>", + "{", + " x.UsingRabbitMq((context, cfg) =>", + " {", + " cfg.Host(builder.Configuration.GetConnectionString(\"RabbitMq\"));", + " });", + "});", + "", + ]) + + +def render_block(lines, joiner="\n"): + block = joiner.join(lines) + return f"{block}\n" if block else "" + + +def render_health_checks(lines): + return "\n".join(lines) + ";\n" + + +def build_infrastructure_pipeline(infra): + if not infra or not infra.database: + return "" + + return "\n".join([ + "using (var scope = app.Services.CreateScope())", + "{", + " var dbContext = scope.ServiceProvider.GetRequiredService();", + " dbContext.Database.EnsureCreated();", + "}", + "", + ]) diff --git a/pse/generators/dotnet/projects.py b/pse/generators/dotnet/projects.py index 9a5f2c1..bcc62ac 100644 --- a/pse/generators/dotnet/projects.py +++ b/pse/generators/dotnet/projects.py @@ -1,6 +1,7 @@ import os from pse.generators.dotnet.process import run_dotnet +from .program import build_program_values from .template_loader import render_template @@ -8,8 +9,6 @@ def archetype_layers(archetype: str): return { "WebApi": ["API", "Application", "Domain", "Infrastructure", "Tests"], "CleanArchitecture": ["Presentation", "Application", "Domain", "Infrastructure"], - "ModularMonolith": ["Modules"], - "Microservices": ["Services", "Gateway", "Shared", "Infrastructure"] }.get(archetype, ["Core"]) @@ -91,184 +90,6 @@ def ensure_program(project_dir: str, project_name: str, layer: str, ctx): f.write(content) -def build_program_values(ctx, layer: str): - infra = ctx.architecture.infrastructure - base = ctx.architecture.project.name - contexts = ctx.architecture.contexts - - using_lines = [] - host_configuration = [] - service_registrations = [] - registrations = [] - pipeline = [] - - has_context_entities = any( - getattr(context, "entities", None) - for context in contexts or [] - ) - - if capability_enabled(ctx, "logging"): - using_lines.append("using Serilog;\n") - host_configuration.extend([ - "builder.Host.UseSerilog((context, services, loggerConfiguration) =>", - " loggerConfiguration", - " .ReadFrom.Configuration(context.Configuration)", - " .ReadFrom.Services(services)", - " .WriteTo.Console());", - "", - ]) - - if capability_enabled(ctx, "validation"): - using_lines.append("using FluentValidation;\n") - using_lines.append("using FluentValidation.AspNetCore;\n") - service_registrations.extend([ - "builder.Services.AddFluentValidationAutoValidation();", - "builder.Services.AddValidatorsFromAssemblyContaining();", - "", - ]) - - if capability_enabled(ctx, "mapping"): - using_lines.append("using Mapster;\n") - using_lines.append("using MapsterMapper;\n") - using_lines.append(f"using {base}.{layer}.Mapping;\n") - service_registrations.extend([ - "MappingConfig.Register();", - "builder.Services.AddMapster();", - "", - ]) - - cqrs_implementation = capability_value(ctx, "cqrs") - first_entity = next( - ( - entity - for context in contexts or [] - for entity in getattr(context, "entities", []) or [] - ), - None, - ) - if has_context_entities: - using_lines.append(f"using {base}.Application.Interfaces;\n") - using_lines.append(f"using {base}.Application.Services;\n") - for context in contexts or []: - for entity in context.entities: - service_registrations.append( - f"builder.Services.AddScoped();" - ) - - service_registrations.append("") - - if cqrs_implementation in {"mediatr", "wolverine"} and has_context_entities: - if cqrs_implementation == "mediatr" and first_entity: - using_lines.append(f"using {base}.Application.Cqrs;\n") - service_registrations.extend([ - f"builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining());", - "", - ]) - - if cqrs_implementation == "wolverine": - using_lines.append("using Wolverine;\n") - if first_entity: - using_lines.append(f"using {base}.Application.Cqrs;\n") - host_configuration.extend([ - "builder.Host.UseWolverine(options =>", - "{", - f" options.Discovery.IncludeAssembly(typeof(GetAll{first_entity.name}Query).Assembly);", - "});", - "", - ]) - else: - host_configuration.extend([ - "builder.Host.UseWolverine();", - "", - ]) - - if has_context_entities: - using_lines.append(f"using {base}.Domain.Repositories;\n") - using_lines.append(f"using {base}.Infrastructure.Repositories;\n") - for context in contexts or []: - for entity in context.entities: - service_registrations.append( - f"builder.Services.AddSingleton();" - ) - - service_registrations.append("") - - if infra and (infra.database or infra.cache or infra.broker): - using_lines.append(f"using {base}.Application.Options;\n") - - if infra and infra.database: - using_lines.append("using Microsoft.EntityFrameworkCore;\n") - using_lines.append(f"using {base}.Infrastructure.Persistence;\n") - registrations.extend([ - "builder.Services.Configure(builder.Configuration.GetSection(\"Database\"));", - "builder.Services.AddDbContext(options =>", - " options.UseNpgsql(builder.Configuration.GetConnectionString(\"Database\")));", - "", - ]) - - if infra and infra.cache: - registrations.extend([ - "builder.Services.Configure(builder.Configuration.GetSection(\"Redis\"));", - "builder.Services.AddStackExchangeRedisCache(options =>", - " options.Configuration = builder.Configuration.GetConnectionString(\"Redis\"));", - "", - ]) - - if infra and infra.broker: - using_lines.append("using MassTransit;\n") - registrations.extend([ - "builder.Services.Configure(builder.Configuration.GetSection(\"RabbitMq\"));", - "builder.Services.AddMassTransit(x =>", - "{", - " x.UsingRabbitMq((context, cfg) =>", - " {", - " cfg.Host(builder.Configuration.GetConnectionString(\"RabbitMq\"));", - " });", - "});", - "", - ]) - - using_block = "".join(using_lines) - host_configuration_block = "\n".join(host_configuration) - service_registrations_block = "\n".join(service_registrations) - registrations_block = "\n".join(registrations) - pipeline_block = "\n".join(pipeline) - - if using_block: - using_block += "\n" - - if host_configuration_block: - host_configuration_block += "\n" - - if service_registrations_block: - service_registrations_block += "\n" - - if registrations_block: - registrations_block += "\n" - - if pipeline_block: - pipeline_block += "\n" - - return { - "UsingLines": using_block, - "HostConfiguration": host_configuration_block, - "ServiceRegistrations": service_registrations_block, - "InfraRegistrations": registrations_block, - "InfraPipeline": pipeline_block, - } - - -def capability_enabled(ctx, name: str): - capabilities = getattr(getattr(ctx, "capabilities", None), "capabilities", {}) or {} - return name.lower() in capabilities - - -def capability_value(ctx, name: str): - capabilities = getattr(getattr(ctx, "capabilities", None), "capabilities", {}) or {} - capability = capabilities.get(name.lower()) - return getattr(capability, "value", None) - - def add_project_references(ctx, output_dir: str, base: str, layers): layer_paths = { layer: os.path.join(output_dir, f"{base}.{layer}", f"{base}.{layer}.csproj") @@ -289,19 +110,13 @@ def add_ref(source_layer: str, target_layer: str): if "API" in layers: add_ref("API", "Application") - if ctx.architecture.infrastructure and (ctx.architecture.infrastructure.database or ctx.architecture.infrastructure.cache or ctx.architecture.infrastructure.broker): - add_ref("API", "Infrastructure") + add_ref("API", "Infrastructure") if "Application" in layers: add_ref("Application", "Domain") if "Presentation" in layers: - if ctx.architecture.infrastructure and (ctx.architecture.infrastructure.database or ctx.architecture.infrastructure.cache or ctx.architecture.infrastructure.broker): - add_ref("Presentation", "Infrastructure") - - if "Gateway" in layers: - if ctx.architecture.infrastructure and (ctx.architecture.infrastructure.database or ctx.architecture.infrastructure.cache or ctx.architecture.infrastructure.broker): - add_ref("Gateway", "Infrastructure") + add_ref("Presentation", "Infrastructure") if "Infrastructure" in layers: add_ref("Infrastructure", "Application") diff --git a/pse/generators/dotnet/structure.py b/pse/generators/dotnet/structure.py index e372b02..ab5984e 100644 --- a/pse/generators/dotnet/structure.py +++ b/pse/generators/dotnet/structure.py @@ -1,6 +1,4 @@ import os - -from .structure_archetypes import create_microservices_structure, create_modular_monolith_structure from .structure_sections import ( create_application_structure, create_api_structure, @@ -17,25 +15,6 @@ def create_structure(ctx): archetype = ctx.architecture.project.archetype contexts = ctx.architecture.contexts - if archetype == "ModularMonolith": - modules_root = os.path.join(output_dir, f"{base}.Modules") - create_modular_monolith_structure(modules_root, contexts) - return - - if archetype == "Microservices": - services_root = os.path.join(output_dir, f"{base}.Services") - gateway_root = os.path.join(output_dir, f"{base}.Gateway") - shared_root = os.path.join(output_dir, f"{base}.Shared") - infra_root = os.path.join(output_dir, f"{base}.Infrastructure") - create_microservices_structure( - services_root, - gateway_root, - shared_root, - infra_root, - contexts, - ) - return - api_root = os.path.join(output_dir, f"{base}.API") presentation_root = os.path.join(output_dir, f"{base}.Presentation") app_root = os.path.join(output_dir, f"{base}.Application") @@ -51,7 +30,7 @@ def create_structure(ctx): create_application_structure(app_root, contexts, ctx) create_domain_structure(domain_root, contexts) - create_infrastructure_structure(infra_root, contexts) + create_infrastructure_structure(infra_root, contexts, ctx) if os.path.isdir(tests_root): create_tests_structure(tests_root, contexts) diff --git a/pse/generators/dotnet/structure_archetypes.py b/pse/generators/dotnet/structure_archetypes.py deleted file mode 100644 index 9416563..0000000 --- a/pse/generators/dotnet/structure_archetypes.py +++ /dev/null @@ -1,86 +0,0 @@ -import os - -from .structure_helpers import context_by_name, ensure_dir, ensure_placeholder -from .structure_sections import ( - create_application_service_files, - create_context_api_files, - create_domain_files, - create_infrastructure_structure, - create_repository_implementations, -) - - -def create_modular_monolith_structure(modules_root: str, contexts): - if not os.path.isdir(modules_root): - return - - ensure_dir(modules_root, "Shared/Kernel") - ensure_placeholder(modules_root, "Shared/Kernel", "ModuleKernel.cs", "Shared kernel placeholder") - - context_list = contexts or [] - module_names = [context.name for context in context_list] or ["ExampleModule"] - - for name in module_names: - module_root = f"Modules/{name}" - selected_context = context_by_name(context_list, name) - - ensure_dir(modules_root, f"{module_root}/API") - ensure_dir(modules_root, f"{module_root}/Application") - ensure_dir(modules_root, f"{module_root}/Domain") - ensure_dir(modules_root, f"{module_root}/Infrastructure") - - ensure_dir(modules_root, f"{module_root}/API/Controllers") - ensure_dir(modules_root, f"{module_root}/API/Dtos") - create_context_api_files(modules_root, [selected_context], root_prefix=f"{module_root}/API") - - ensure_dir(modules_root, f"{module_root}/Application/Interfaces") - ensure_dir(modules_root, f"{module_root}/Application/Services") - ensure_dir(modules_root, f"{module_root}/Domain/Entities") - ensure_dir(modules_root, f"{module_root}/Domain/Repositories") - ensure_dir(modules_root, f"{module_root}/Infrastructure/Persistence") - - create_application_service_files(modules_root, [selected_context], root_prefix=f"{module_root}/Application") - create_domain_files(modules_root, [selected_context], root_prefix=f"{module_root}/Domain") - ensure_placeholder(modules_root, f"{module_root}/Infrastructure/Persistence", "PersistenceOptions.cs", "Module persistence options") - create_repository_implementations(modules_root, [selected_context], root_prefix=f"{module_root}/Infrastructure") - - -def create_microservices_structure(services_root: str, gateway_root: str, shared_root: str, infra_root: str, contexts): - if os.path.isdir(gateway_root): - ensure_dir(gateway_root, "Controllers") - ensure_dir(gateway_root, "Routes") - ensure_placeholder(gateway_root, "Controllers", "GatewayController.cs", "Gateway controller") - ensure_placeholder(gateway_root, "Routes", "Routes.cs", "Gateway route definitions") - - if os.path.isdir(shared_root): - ensure_dir(shared_root, "Contracts") - ensure_dir(shared_root, "Dtos") - ensure_placeholder(shared_root, "Contracts", "SharedContracts.cs", "Shared contracts") - ensure_placeholder(shared_root, "Dtos", "SharedDtos.cs", "Shared DTOs") - - if os.path.isdir(infra_root): - create_infrastructure_structure(infra_root, contexts) - - if not os.path.isdir(services_root): - return - - context_list = contexts or [] - service_names = [context.name for context in context_list] or ["ExampleService"] - - for name in service_names: - service_root = f"Services/{name}" - selected_context = context_by_name(context_list, name) - - ensure_dir(services_root, f"{service_root}/API/Controllers") - ensure_dir(services_root, f"{service_root}/API/Dtos") - ensure_dir(services_root, f"{service_root}/Application/Interfaces") - ensure_dir(services_root, f"{service_root}/Application/Services") - ensure_dir(services_root, f"{service_root}/Domain/Entities") - ensure_dir(services_root, f"{service_root}/Domain/Repositories") - ensure_dir(services_root, f"{service_root}/Infrastructure/Persistence") - - create_context_api_files(services_root, [selected_context], root_prefix=f"{service_root}/API") - create_application_service_files(services_root, [selected_context], root_prefix=f"{service_root}/Application") - create_domain_files(services_root, [selected_context], root_prefix=f"{service_root}/Domain") - ensure_placeholder(services_root, f"{service_root}/Infrastructure/Persistence", "PersistenceOptions.cs", "Service persistence options") - create_repository_implementations(services_root, [selected_context], root_prefix=f"{service_root}/Infrastructure") diff --git a/pse/generators/dotnet/structure_helpers.py b/pse/generators/dotnet/structure_helpers.py index 932bd07..37b92c8 100644 --- a/pse/generators/dotnet/structure_helpers.py +++ b/pse/generators/dotnet/structure_helpers.py @@ -1,41 +1,10 @@ import os -from .template_loader import render_template - - def ensure_dir(root: str, name: str): path = os.path.join(root, name) os.makedirs(path, exist_ok=True) -def ensure_placeholder(root: str, folder: str, filename: str, description: str): - path = os.path.join(root, folder, filename) - if os.path.exists(path): - return - - class_name = os.path.splitext(filename)[0] - namespace = build_namespace(path, folder) - - content = render_template( - "CSharpClass.cs.tmpl", - { - "UsingLines": "", - "Namespace": namespace, - "Signature": f"public class {class_name}", - "Body": f" // {description}.\n", - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def remove_placeholder(root: str, folder: str, filename: str): - path = os.path.join(root, folder, filename) - if os.path.exists(path): - os.remove(path) - - def context_by_name(contexts, name: str): for context in contexts or []: if context.name == name: @@ -111,7 +80,8 @@ def pick_id_type(properties): if prop_name.lower() == "id": return map_type(prop_type)[0] - return "Guid" + first_type = next(iter(properties.values())) + return map_type(first_type)[0] def pick_id_property_name(properties): diff --git a/pse/generators/dotnet/structure_sections.py b/pse/generators/dotnet/structure_sections.py index 386dfa3..f3d39b4 100644 --- a/pse/generators/dotnet/structure_sections.py +++ b/pse/generators/dotnet/structure_sections.py @@ -1,7 +1,9 @@ import os -from .structure_helpers import ensure_dir, ensure_placeholder, remove_placeholder -from .structure_writers import ( +from .capabilities import capability_enabled, capability_value +from .structure_helpers import ensure_dir +from .writers import ( + write_aggregate_class, write_controller, write_csharp_class, write_mediatr_cqrs_class, @@ -26,7 +28,7 @@ def create_api_structure(api_root: str, contexts, ctx=None): if capability_enabled(ctx, "mapping"): ensure_dir(api_root, "Mapping") - created = create_context_api_files( + create_context_api_files( api_root, contexts, use_mapping=capability_enabled(ctx, "mapping"), @@ -37,11 +39,6 @@ def create_api_structure(api_root: str, contexts, ctx=None): if capability_enabled(ctx, "mapping"): create_mapping_files(api_root, contexts) - if not created: - ensure_placeholder(api_root, "Controllers", "ExampleController.cs", "API controller skeleton") - ensure_placeholder(api_root, "Dtos", "ExampleDto.cs", "API DTO skeleton") - ensure_placeholder(api_root, "Contracts", "ExampleRequest.cs", "API contract request skeleton") - ensure_placeholder(api_root, "Contracts", "ExampleResponse.cs", "API contract response skeleton") def create_presentation_structure(presentation_root: str, contexts, ctx=None): @@ -53,7 +50,7 @@ def create_presentation_structure(presentation_root: str, contexts, ctx=None): if capability_enabled(ctx, "mapping"): ensure_dir(presentation_root, "Mapping") - created = create_context_api_files( + create_context_api_files( presentation_root, contexts, use_mapping=capability_enabled(ctx, "mapping"), @@ -64,11 +61,6 @@ def create_presentation_structure(presentation_root: str, contexts, ctx=None): if capability_enabled(ctx, "mapping"): create_mapping_files(presentation_root, contexts) - if not created: - ensure_placeholder(presentation_root, "Controllers", "ExampleController.cs", "Presentation controller skeleton") - ensure_placeholder(presentation_root, "Dtos", "ExampleDto.cs", "Presentation DTO skeleton") - ensure_placeholder(presentation_root, "Contracts", "ExampleRequest.cs", "Presentation request skeleton") - ensure_placeholder(presentation_root, "Contracts", "ExampleResponse.cs", "Presentation response skeleton") def create_application_structure(app_root: str, contexts, ctx=None): @@ -77,58 +69,66 @@ def create_application_structure(app_root: str, contexts, ctx=None): cqrs_implementation = capability_value(ctx, "cqrs") if cqrs_implementation: ensure_dir(app_root, "Cqrs") + cleanup_inactive_cqrs_files(app_root, contexts, cqrs_implementation) - created = create_application_service_files(app_root, contexts) - cqrs_created = create_cqrs_files(app_root, contexts, cqrs_implementation) + create_application_service_files(app_root, contexts) + create_cqrs_files(app_root, contexts, cqrs_implementation) - if not created and not cqrs_created: - ensure_placeholder(app_root, "Interfaces", "IExampleService.cs", "Application service interface") - ensure_placeholder(app_root, "Services", "ExampleService.cs", "Application service implementation") + + +def cleanup_inactive_cqrs_files(app_root: str, contexts, implementation: str = None): + active_suffix = { + "mediatr": "Requests.cs", + "wolverine": "Messages.cs", + }.get(implementation) + cqrs_root = os.path.join(app_root, "Cqrs") + if not os.path.isdir(cqrs_root): + return + + expected = { + f"{entity.name}{active_suffix}" + for context in contexts or [] + for entity in getattr(context, "entities", []) or [] + if active_suffix + } + for file_name in os.listdir(cqrs_root): + is_generated_cqrs = file_name.endswith(("Requests.cs", "Messages.cs")) + if is_generated_cqrs and file_name not in expected: + os.remove(os.path.join(cqrs_root, file_name)) def create_domain_structure(domain_root: str, contexts): + ensure_dir(domain_root, "Aggregates") ensure_dir(domain_root, "Entities") ensure_dir(domain_root, "ValueObjects") ensure_dir(domain_root, "Repositories") ensure_dir(domain_root, "Events") - created = create_domain_files(domain_root, contexts) + create_domain_files(domain_root, contexts) - if not created: - ensure_placeholder(domain_root, "Entities", "ExampleEntity.cs", "Domain entity") - ensure_placeholder(domain_root, "ValueObjects", "ExampleValueObject.cs", "Domain value object") - ensure_placeholder(domain_root, "Repositories", "IExampleRepository.cs", "Repository interface") - ensure_placeholder(domain_root, "Events", "ExampleDomainEvent.cs", "Domain event") -def create_infrastructure_structure(infra_root: str, contexts): +def create_infrastructure_structure(infra_root: str, contexts, ctx=None): ensure_dir(infra_root, "Persistence") ensure_dir(infra_root, "Repositories") ensure_dir(infra_root, "Messaging") - ensure_placeholder(infra_root, "Persistence", "PersistenceOptions.cs", "Infrastructure persistence options") - ensure_placeholder(infra_root, "Messaging", "MessageBusOptions.cs", "Infrastructure messaging options") - - created = create_repository_implementations(infra_root, contexts) - if not created: - ensure_placeholder(infra_root, "Repositories", "ExampleRepository.cs", "Repository implementation") - else: - remove_placeholder(infra_root, "Repositories", "ExampleRepository.cs") + create_repository_implementations( + infra_root, + contexts, + use_database=capability_value(ctx, "database") == "postgres", + ) def create_tests_structure(tests_root: str, contexts): ensure_dir(tests_root, "Unit") ensure_dir(tests_root, "Integration") - created = create_tests_files(tests_root, contexts) + create_tests_files(tests_root, contexts) - if not created: - ensure_placeholder(tests_root, "Unit", "ExampleUnitTests.cs", "Unit test skeleton") - ensure_placeholder(tests_root, "Integration", "ExampleIntegrationTests.cs", "Integration test skeleton") - else: - remove_placeholder(tests_root, "Unit", "ExampleUnitTests.cs") - remove_placeholder(tests_root, "Integration", "ExampleIntegrationTests.cs") def create_context_api_files(root: str, contexts, root_prefix: str = "", use_mapping: bool = False, cqrs_implementation: str = None): created = False + entity_names, value_object_names = domain_type_names(contexts) + project_name = os.path.basename(root).split(".")[0] for context in contexts or []: if not context: continue @@ -147,7 +147,18 @@ def create_context_api_files(root: str, contexts, root_prefix: str = "", use_map use_mapping=use_mapping, cqrs_implementation=cqrs_implementation, ) - write_csharp_class(dto_path, "Dtos", dto_name, properties=entity.properties) + write_csharp_class( + dto_path, + "Dtos", + dto_name, + properties=entity.properties, + additional_usings=property_type_usings( + entity.properties, + entity_names, + value_object_names, + f"{project_name}.Domain", + ), + ) created = True return created @@ -247,12 +258,25 @@ def create_cqrs_files(root: str, contexts, implementation: str = None, root_pref def create_domain_files(root: str, contexts, root_prefix: str = ""): created = False + entity_names, value_object_names = domain_type_names(contexts) + domain_namespace = os.path.basename(root).split(".")[0] + ".Domain" for context in contexts or []: if not context: continue for entity in context.entities: entity_path = os.path.join(root, root_prefix, "Entities", f"{entity.name}.cs") - write_csharp_class(entity_path, "Entities", entity.name, properties=entity.properties) + write_csharp_class( + entity_path, + "Entities", + entity.name, + properties=entity.properties, + additional_usings=property_type_usings( + entity.properties, + entity_names, + value_object_names, + domain_namespace, + ), + ) repo_name = f"I{entity.name}Repository" repo_path = os.path.join(root, root_prefix, "Repositories", f"{repo_name}.cs") write_repository_interface(repo_path, "Repositories", repo_name, entity.name, pick_id_type(entity.properties)) @@ -260,13 +284,53 @@ def create_domain_files(root: str, contexts, root_prefix: str = ""): for value_object in context.value_objects: vo_path = os.path.join(root, root_prefix, "ValueObjects", f"{value_object.name}.cs") - write_csharp_class(vo_path, "ValueObjects", value_object.name, properties=value_object.properties) + write_csharp_class( + vo_path, + "ValueObjects", + value_object.name, + properties=value_object.properties, + additional_usings=property_type_usings( + value_object.properties, + entity_names, + value_object_names, + domain_namespace, + ), + ) + created = True + + for aggregate in context.aggregates: + aggregate_path = os.path.join(root, root_prefix, "Aggregates", f"{aggregate.name}.cs") + write_aggregate_class(aggregate_path, "Aggregates", aggregate) created = True return created -def create_repository_implementations(root: str, contexts, root_prefix: str = ""): +def domain_type_names(contexts): + entity_names = { + entity.name.lower() + for context in contexts or [] + for entity in context.entities + } + value_object_names = { + value_object.name.lower() + for context in contexts or [] + for value_object in context.value_objects + } + return entity_names, value_object_names + + +def property_type_usings(properties, entity_names, value_object_names, domain_namespace): + property_types = {value.lower() for value in (properties or {}).values()} + usings = [] + if property_types & entity_names: + usings.append(f"{domain_namespace}.Entities") + if property_types & value_object_names: + usings.append(f"{domain_namespace}.ValueObjects") + return usings + + +def create_repository_implementations(root: str, contexts, root_prefix: str = "", use_database: bool = False): created = False for context in contexts or []: if not context: @@ -283,6 +347,7 @@ def create_repository_implementations(root: str, contexts, root_prefix: str = "" entity.name, pick_id_type(entity.properties), pick_id_property_name(entity.properties), + use_database=use_database, ) created = True @@ -301,14 +366,3 @@ def create_tests_files(root: str, contexts): created = True return created - - -def capability_enabled(ctx, name: str): - capabilities = getattr(getattr(ctx, "capabilities", None), "capabilities", {}) or {} - return name.lower() in capabilities - - -def capability_value(ctx, name: str): - capabilities = getattr(getattr(ctx, "capabilities", None), "capabilities", {}) or {} - capability = capabilities.get(name.lower()) - return getattr(capability, "value", None) diff --git a/pse/generators/dotnet/structure_writers.py b/pse/generators/dotnet/structure_writers.py index 96efe65..df0fba4 100644 --- a/pse/generators/dotnet/structure_writers.py +++ b/pse/generators/dotnet/structure_writers.py @@ -1,650 +1,4 @@ -import os +"""Backward-compatible imports for the writer package.""" -from .template_loader import render_template -from .structure_helpers import build_namespace, build_properties, map_type, pick_id_property_name, pick_id_type - - -def write_csharp_class(path: str, folder: str, name: str, base_type: str = None, properties=None, is_interface: bool = False): - if os.path.exists(path): - return - - namespace = build_namespace(path, folder) - prop_lines, needs_system = build_properties(properties or {}) - using_lines = "using System;\n\n" if needs_system else "" - - if is_interface: - signature = f"public interface {name}" - else: - signature = f"public class {name}" - if base_type: - signature += f" : {base_type}" - - content = render_template( - "CSharpClass.cs.tmpl", - { - "UsingLines": using_lines, - "Namespace": namespace, - "Signature": signature, - "Body": prop_lines, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_repository_class(path: str, folder: str, name: str, interface_name: str, entity_name: str, id_type: str, id_property_name: str): - namespace = build_namespace(path, folder) - domain_namespace = namespace.replace(".Infrastructure.Repositories", ".Domain") - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if ( - "GetAll()" in content - and "GetById(" in content - and "Create(" in content - and "NotImplementedException" not in content - and "Array.Empty" not in content - and "{{" not in content - and "Repositories.Entities" not in content - and "Repositories.Repositories" not in content - ): - return - - content = render_template( - "RepositoryImplementation.cs.tmpl", - { - "DomainNamespace": domain_namespace, - "EntityName": entity_name, - "IdType": id_type, - "IdPropertyName": id_property_name, - "Namespace": namespace, - "ClassName": name, - "InterfaceName": interface_name, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_service_interface(path: str, folder: str, name: str, entity_name: str, id_type: str): - namespace = build_namespace(path, folder) - domain_namespace = namespace.replace(".Application.Interfaces", ".Domain") - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if ( - "GetAll()" in content - and "GetById(" in content - and "Create(" in content - and "bool Update(" in content - and "bool Delete(" in content - and "{{" not in content - ): - return - - content = render_template( - "ApplicationServiceInterface.cs.tmpl", - { - "DomainNamespace": domain_namespace, - "Namespace": namespace, - "InterfaceName": name, - "EntityName": entity_name, - "IdType": id_type, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_service_class(path: str, folder: str, name: str, interface_name: str, entity_name: str, id_type: str, id_property_name: str): - namespace = build_namespace(path, folder) - domain_namespace = namespace.replace(".Application.Services", ".Domain") - interface_namespace = namespace.replace(".Services", ".Interfaces") - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if ( - "GetAll()" in content - and "GetById(" in content - and "Create(" in content - and "bool Update(" in content - and "bool Delete(" in content - and "{{" not in content - and "TODO" not in content - ): - return - - content = render_template( - "ApplicationService.cs.tmpl", - { - "DomainNamespace": domain_namespace, - "InterfaceNamespace": interface_namespace, - "Namespace": namespace, - "ClassName": name, - "InterfaceName": interface_name, - "EntityName": entity_name, - "IdType": id_type, - "IdPropertyName": id_property_name, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_repository_interface(path: str, folder: str, name: str, entity_name: str, id_type: str): - namespace = build_namespace(path, folder) - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if ( - "GetAll()" in content - and "GetById(" in content - and "Create(" in content - and "{{" not in content - ): - return - - content = render_template( - "RepositoryInterface.cs.tmpl", - { - "DomainNamespace": namespace.replace(".Repositories", ""), - "Namespace": namespace, - "InterfaceName": name, - "EntityName": entity_name, - "IdType": id_type, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_validator_class(path: str, folder: str, name: str, dto_name: str, properties): - namespace = build_namespace(path, folder) - dto_namespace = build_namespace(path, "Dtos") - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if "AbstractValidator" in content and "{{" not in content: - return - - rules = "".join( - f" RuleFor(x => x.{prop_name}).NotEmpty();\n" - for prop_name in (properties or {}).keys() - ) - - content = render_template( - "DtoValidator.cs.tmpl", - { - "DtoNamespace": dto_namespace, - "Namespace": namespace, - "ClassName": name, - "DtoName": dto_name, - "Rules": rules, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_mapping_config(path: str, folder: str, entities): - namespace = build_namespace(path, folder) - project_root = namespace.split(".")[0] - surface_namespace = namespace.rsplit(".", 1)[0] - dto_namespace = f"{surface_namespace}.Dtos" - entity_namespace = f"{project_root}.Domain.Entities" - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if "TypeAdapterConfig" in content and "{{" not in content: - return - - mappings = [] - for entity in entities or []: - dto_name = f"{entity.name}Dto" - mappings.append(f" TypeAdapterConfig<{entity.name}, {dto_name}>.NewConfig();\n") - mappings.append(f" TypeAdapterConfig<{dto_name}, {entity.name}>.NewConfig();\n") - - content = render_template( - "MappingConfig.cs.tmpl", - { - "DtoNamespace": dto_namespace, - "EntityNamespace": entity_namespace, - "Namespace": namespace, - "Mappings": "".join(mappings), - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_mediatr_cqrs_class(path: str, folder: str, entity_name: str, id_type: str): - write_cqrs_class(path, folder, entity_name, id_type, "MediatRRequests.cs.tmpl") - - -def write_wolverine_cqrs_class(path: str, folder: str, entity_name: str, id_type: str): - write_cqrs_class(path, folder, entity_name, id_type, "WolverineMessages.cs.tmpl") - - -def write_cqrs_class(path: str, folder: str, entity_name: str, id_type: str, template_name: str): - namespace = build_namespace(path, folder) - project_root = namespace.split(".")[0] - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if "Handler" in content and "{{" not in content and "TODO" not in content: - return - - content = render_template( - template_name, - { - "Namespace": namespace, - "DomainNamespace": f"{project_root}.Domain", - "InterfaceNamespace": f"{project_root}.Application.Interfaces", - "EntityName": entity_name, - "IdType": id_type, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def write_controller(path: str, folder: str, name: str, entity, dto_name: str, use_mapping: bool = False, cqrs_implementation: str = None): - namespace = build_namespace(path, folder) - entity_namespace = namespace.replace(".API.Controllers", ".Domain.Entities").replace(".Presentation.Controllers", ".Domain.Entities").replace(".Gateway.Controllers", ".Domain.Entities") - service_namespace = namespace.replace(".API.Controllers", ".Application.Interfaces").replace(".Presentation.Controllers", ".Application.Interfaces").replace(".Gateway.Controllers", ".Application.Interfaces") - cqrs_namespace = namespace.replace(".API.Controllers", ".Application.Cqrs").replace(".Presentation.Controllers", ".Application.Cqrs").replace(".Gateway.Controllers", ".Application.Cqrs") - dto_namespace = build_namespace(path, "Dtos") - id_type = pick_id_type(entity.properties) - id_property_name = pick_id_property_name(entity.properties) - cqrs_implementation = (cqrs_implementation or "").lower() - - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - if ( - "private readonly" in content - and "{{" not in content - and "TODO" not in content - and ( - use_mapping - or ("ToDto(" in content and "ToEntity(" in content) - ) - and ( - (cqrs_implementation == "mediatr" and "IMediator" in content) - or (cqrs_implementation == "wolverine" and "IMessageBus" in content) - or (cqrs_implementation not in {"mediatr", "wolverine"} and f"I{entity.name}Service" in content) - ) - ): - return - - methods = build_controller_methods( - entity_name=entity.name, - dto_name=dto_name, - id_type=id_type, - id_property_name=id_property_name, - properties=list(entity.properties.keys()), - use_mapping=use_mapping, - cqrs_implementation=cqrs_implementation, - ) - - using_lines = ( - "using System.Collections.Generic;\n" - "using System.Linq;\n" - "using System.Threading.Tasks;\n" - f"using {dto_namespace};\n" - f"using {entity_namespace};\n" - ) - if cqrs_implementation in {"mediatr", "wolverine"}: - using_lines += f"using {cqrs_namespace};\n" - else: - using_lines += f"using {service_namespace};\n" - - if cqrs_implementation == "mediatr": - using_lines += "using MediatR;\n" - elif cqrs_implementation == "wolverine": - using_lines += "using Wolverine;\n" - - if use_mapping: - using_lines += "using Mapster;\n" - - content = render_template( - "Controller.cs.tmpl", - { - "UsingLines": f"{using_lines}\n", - "Namespace": namespace, - "ControllerName": name, - "Methods": methods, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def build_controller_methods(entity_name: str, dto_name: str, id_type: str, id_property_name: str, properties, use_mapping: bool = False, cqrs_implementation: str = None): - if cqrs_implementation == "mediatr": - return build_mediatr_controller_methods(entity_name, dto_name, id_type, id_property_name, properties, use_mapping) - - if cqrs_implementation == "wolverine": - return build_wolverine_controller_methods(entity_name, dto_name, id_type, id_property_name, properties, use_mapping) - - service_field_name = f"_{entity_name[0].lower()}{entity_name[1:]}Service" - service_parameter_name = f"{entity_name[0].lower()}{entity_name[1:]}Service" - service_interface_name = f"I{entity_name}Service" - - core_methods = ( - f" private readonly {service_interface_name} {service_field_name};\n\n" - f" public {entity_name}Controller({service_interface_name} {service_parameter_name})\n" - " {\n" - f" {service_field_name} = {service_parameter_name};\n" - " }\n\n" - " [HttpGet]\n" - f" public ActionResult> GetAll()\n" - " {\n" - f" var entities = {service_field_name}.GetAll().Select(entity => {map_expression('entity', dto_name, use_mapping)}).ToList();\n" - " return Ok(entities);\n" - " }\n\n" - " [HttpGet(\"{id}\")]\n" - f" public ActionResult<{dto_name}> GetById({id_type} id)\n" - " {\n" - f" var entity = {service_field_name}.GetById(id);\n" - " if (entity is null)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - f" return Ok({map_expression('entity', dto_name, use_mapping)});\n" - " }\n\n" - " [HttpPost]\n" - f" public ActionResult<{dto_name}> Create({dto_name} request)\n" - " {\n" - f" var entity = {map_expression('request', entity_name, use_mapping)};\n" - f" var created = {service_field_name}.Create(entity);\n" - f" return CreatedAtAction(nameof(GetById), new {{ id = created.{id_property_name} }}, {map_expression('created', dto_name, use_mapping)});\n" - " }\n\n" - " [HttpPut(\"{id}\")]\n" - f" public IActionResult Update({id_type} id, {dto_name} request)\n" - " {\n" - f" var entity = {map_expression('request', entity_name, use_mapping)};\n" - f" if (!{service_field_name}.Update(id, entity))\n" - " {\n" - " return NotFound();\n" - " }\n\n" - " return NoContent();\n" - " }\n\n" - " [HttpDelete(\"{id}\")]\n" - f" public IActionResult Delete({id_type} id)\n" - " {\n" - f" if (!{service_field_name}.Delete(id))\n" - " {\n" - " return NotFound();\n" - " }\n\n" - " return NoContent();\n" - " }\n" - ) - - if use_mapping: - return core_methods - - return core_methods + build_manual_mapping_methods(entity_name, dto_name, properties) - - -def build_mediatr_controller_methods(entity_name: str, dto_name: str, id_type: str, id_property_name: str, properties, use_mapping: bool = False): - core_methods = ( - " private readonly IMediator _mediator;\n\n" - f" public {entity_name}Controller(IMediator mediator)\n" - " {\n" - " _mediator = mediator;\n" - " }\n\n" - " [HttpGet]\n" - f" public async Task>> GetAll()\n" - " {\n" - f" var entities = await _mediator.Send(new GetAll{entity_name}Query());\n" - f" var response = entities.Select(entity => {map_expression('entity', dto_name, use_mapping)}).ToList();\n" - " return Ok(response);\n" - " }\n\n" - " [HttpGet(\"{id}\")]\n" - f" public async Task> GetById({id_type} id)\n" - " {\n" - f" var entity = await _mediator.Send(new Get{entity_name}ByIdQuery(id));\n" - " if (entity is null)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - f" return Ok({map_expression('entity', dto_name, use_mapping)});\n" - " }\n\n" - " [HttpPost]\n" - f" public async Task> Create({dto_name} request)\n" - " {\n" - f" var entity = {map_expression('request', entity_name, use_mapping)};\n" - f" var created = await _mediator.Send(new Create{entity_name}Command(entity));\n" - f" return CreatedAtAction(nameof(GetById), new {{ id = created.{id_property_name} }}, {map_expression('created', dto_name, use_mapping)});\n" - " }\n\n" - " [HttpPut(\"{id}\")]\n" - f" public async Task Update({id_type} id, {dto_name} request)\n" - " {\n" - f" var entity = {map_expression('request', entity_name, use_mapping)};\n" - f" var updated = await _mediator.Send(new Update{entity_name}Command(id, entity));\n" - " if (!updated)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - " return NoContent();\n" - " }\n\n" - " [HttpDelete(\"{id}\")]\n" - f" public async Task Delete({id_type} id)\n" - " {\n" - f" var deleted = await _mediator.Send(new Delete{entity_name}Command(id));\n" - " if (!deleted)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - " return NoContent();\n" - " }\n" - ) - - if use_mapping: - return core_methods - - return core_methods + build_manual_mapping_methods(entity_name, dto_name, properties) - - -def build_wolverine_controller_methods(entity_name: str, dto_name: str, id_type: str, id_property_name: str, properties, use_mapping: bool = False): - core_methods = ( - " private readonly IMessageBus _messageBus;\n\n" - f" public {entity_name}Controller(IMessageBus messageBus)\n" - " {\n" - " _messageBus = messageBus;\n" - " }\n\n" - " [HttpGet]\n" - f" public async Task>> GetAll()\n" - " {\n" - f" var entities = await _messageBus.InvokeAsync>(new GetAll{entity_name}Query());\n" - f" var response = entities.Select(entity => {map_expression('entity', dto_name, use_mapping)}).ToList();\n" - " return Ok(response);\n" - " }\n\n" - " [HttpGet(\"{id}\")]\n" - f" public async Task> GetById({id_type} id)\n" - " {\n" - f" var entity = await _messageBus.InvokeAsync<{entity_name}?>(new Get{entity_name}ByIdQuery(id));\n" - " if (entity is null)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - f" return Ok({map_expression('entity', dto_name, use_mapping)});\n" - " }\n\n" - " [HttpPost]\n" - f" public async Task> Create({dto_name} request)\n" - " {\n" - f" var entity = {map_expression('request', entity_name, use_mapping)};\n" - f" var created = await _messageBus.InvokeAsync<{entity_name}>(new Create{entity_name}Command(entity));\n" - f" return CreatedAtAction(nameof(GetById), new {{ id = created.{id_property_name} }}, {map_expression('created', dto_name, use_mapping)});\n" - " }\n\n" - " [HttpPut(\"{id}\")]\n" - f" public async Task Update({id_type} id, {dto_name} request)\n" - " {\n" - f" var entity = {map_expression('request', entity_name, use_mapping)};\n" - f" var updated = await _messageBus.InvokeAsync(new Update{entity_name}Command(id, entity));\n" - " if (!updated)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - " return NoContent();\n" - " }\n\n" - " [HttpDelete(\"{id}\")]\n" - f" public async Task Delete({id_type} id)\n" - " {\n" - f" var deleted = await _messageBus.InvokeAsync(new Delete{entity_name}Command(id));\n" - " if (!deleted)\n" - " {\n" - " return NotFound();\n" - " }\n\n" - " return NoContent();\n" - " }\n" - ) - - if use_mapping: - return core_methods - - return core_methods + build_manual_mapping_methods(entity_name, dto_name, properties) - - -def build_manual_mapping_methods(entity_name: str, dto_name: str, properties): - to_dto_assignments = build_object_initializer("entity", dto_name, properties) - to_entity_assignments = build_object_initializer("dto", entity_name, properties) - - return ( - "\n" - f" private static {dto_name} ToDto({entity_name} entity)\n" - " {\n" - f" return new {dto_name}\n" - " {\n" - f"{to_dto_assignments}" - " };\n" - " }\n\n" - f" private static {entity_name} ToEntity({dto_name} dto)\n" - " {\n" - f" return new {entity_name}\n" - " {\n" - f"{to_entity_assignments}" - " };\n" - " }\n" - ) - - -def map_expression(source_name: str, target_name: str, use_mapping: bool): - if use_mapping: - return f"{source_name}.Adapt<{target_name}>()" - - if target_name.endswith("Dto"): - return f"ToDto({source_name})" - - return f"ToEntity({source_name})" - - -def build_object_initializer(source_name: str, target_name: str, properties): - if not properties: - return "" - - lines = [] - for prop_name in properties: - lines.append(f" {prop_name} = {source_name}.{prop_name},\n") - - return "".join(lines) - - -def write_test_class(path: str, folder: str, name: str, entity): - namespace = build_namespace(path, folder) - project_root = namespace.split(".")[0] - subject_name = entity.name - body = build_entity_test_body(entity) - content = render_template( - "TestClass.cs.tmpl", - { - "UsingLines": f"using System;\nusing {project_root}.Domain.Entities;\n", - "Namespace": namespace, - "ClassName": name, - "TestMethodName": f"CanCreate{subject_name}", - "Body": body, - }, - ) - - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def build_entity_test_body(entity): - assignments = [] - assertions = [] - - for prop_name, prop_type in (entity.properties or {}).items(): - cs_type, _ = map_type(prop_type) - value = sample_value(prop_name, cs_type) - assignments.append(f" {prop_name} = {value},\n") - assertions.append(assertion_for_property(prop_name, cs_type, value)) - - if not assignments: - return f" var entity = new {entity.name}();\n\n Assert.NotNull(entity);\n" - - return ( - f" var entity = new {entity.name}\n" - " {\n" - f"{''.join(assignments)}" - " };\n\n" - f"{''.join(assertions)}" - ) - - -def sample_value(prop_name: str, cs_type: str): - if cs_type == "Guid": - return "Guid.NewGuid()" - if cs_type == "DateTime": - return "DateTime.UtcNow" - if cs_type == "string": - return f"\"{prop_name}\"" - if cs_type == "bool": - return "true" - if cs_type in {"int", "long"}: - return "1" - if cs_type == "decimal": - return "1m" - if cs_type == "double": - return "1d" - if cs_type == "float": - return "1f" - - return f"new {cs_type}()" - - -def assertion_for_property(prop_name: str, cs_type: str, value: str): - if cs_type == "Guid": - return f" Assert.NotEqual(Guid.Empty, entity.{prop_name});\n" - if cs_type == "DateTime": - return f" Assert.NotEqual(default, entity.{prop_name});\n" - if cs_type == "string": - return f" Assert.Equal({value}, entity.{prop_name});\n" - if cs_type == "bool": - return f" Assert.True(entity.{prop_name});\n" - - return f" Assert.Equal({value}, entity.{prop_name});\n" +from .writers import * +from .writers import __all__ diff --git a/pse/generators/dotnet/template_loader.py b/pse/generators/dotnet/template_loader.py index 3fe9a1f..63d6492 100644 --- a/pse/generators/dotnet/template_loader.py +++ b/pse/generators/dotnet/template_loader.py @@ -1,18 +1,5 @@ -import os - -TEMPLATE_ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "templates", "dotnet") -) +from pse.template_loader import render_template as render_project_template def render_template(name: str, values: dict): - path = os.path.join(TEMPLATE_ROOT, name) - - with open(path, "r", encoding="utf-8") as f: - content = f.read() - - for key, value in values.items(): - placeholder = "{{" + key + "}}" - content = content.replace(placeholder, value) - - return content + return render_project_template(f"dotnet/{name}", values) diff --git a/pse/generators/dotnet/writers/__init__.py b/pse/generators/dotnet/writers/__init__.py new file mode 100644 index 0000000..8c37d74 --- /dev/null +++ b/pse/generators/dotnet/writers/__init__.py @@ -0,0 +1,58 @@ +"""Public exports for focused .NET source writers.""" + +from .api_support import write_mapping_config, write_validator_class +from .controller_cqrs import ( + build_mediatr_controller_methods, + build_wolverine_controller_methods, +) +from .controller_mapping import ( + build_manual_mapping_methods, + build_object_initializer, + map_expression, +) +from .controllers import build_controller_methods, write_controller +from .cqrs import ( + write_cqrs_class, + write_mediatr_cqrs_class, + write_wolverine_cqrs_class, +) +from .model import ( + write_aggregate_class, + write_csharp_class, + write_repository_class, + write_repository_interface, + write_service_class, + write_service_interface, +) +from .tests import ( + assertion_for_property, + build_entity_test_body, + sample_value, + write_test_class, +) + + +__all__ = [ + "assertion_for_property", + "build_controller_methods", + "build_entity_test_body", + "build_manual_mapping_methods", + "build_mediatr_controller_methods", + "build_object_initializer", + "build_wolverine_controller_methods", + "map_expression", + "sample_value", + "write_controller", + "write_aggregate_class", + "write_cqrs_class", + "write_csharp_class", + "write_mapping_config", + "write_mediatr_cqrs_class", + "write_repository_class", + "write_repository_interface", + "write_service_class", + "write_service_interface", + "write_test_class", + "write_validator_class", + "write_wolverine_cqrs_class", +] diff --git a/pse/generators/dotnet/writers/api_support.py b/pse/generators/dotnet/writers/api_support.py new file mode 100644 index 0000000..1d69a40 --- /dev/null +++ b/pse/generators/dotnet/writers/api_support.py @@ -0,0 +1,69 @@ +import os + +from ..structure_helpers import build_namespace +from ..template_loader import render_template + + +def write_validator_class(path: str, folder: str, name: str, dto_name: str, properties): + namespace = build_namespace(path, folder) + dto_namespace = build_namespace(path, "Dtos") + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if "AbstractValidator" in content and "{{" not in content: + return + + rules = "".join( + f" RuleFor(x => x.{prop_name}).NotEmpty();\n" + for prop_name in (properties or {}).keys() + ) + + content = render_template( + "DtoValidator.cs.tmpl", + { + "DtoNamespace": dto_namespace, + "Namespace": namespace, + "ClassName": name, + "DtoName": dto_name, + "Rules": rules, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def write_mapping_config(path: str, folder: str, entities): + namespace = build_namespace(path, folder) + project_root = namespace.split(".")[0] + surface_namespace = namespace.rsplit(".", 1)[0] + dto_namespace = f"{surface_namespace}.Dtos" + entity_namespace = f"{project_root}.Domain.Entities" + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if "TypeAdapterConfig" in content and "{{" not in content: + return + + mappings = [] + for entity in entities or []: + dto_name = f"{entity.name}Dto" + mappings.append(f" TypeAdapterConfig<{entity.name}, {dto_name}>.NewConfig();\n") + mappings.append(f" TypeAdapterConfig<{dto_name}, {entity.name}>.NewConfig();\n") + + content = render_template( + "MappingConfig.cs.tmpl", + { + "DtoNamespace": dto_namespace, + "EntityNamespace": entity_namespace, + "Namespace": namespace, + "Mappings": "".join(mappings), + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) diff --git a/pse/generators/dotnet/writers/controller_cqrs.py b/pse/generators/dotnet/writers/controller_cqrs.py new file mode 100644 index 0000000..e1fd524 --- /dev/null +++ b/pse/generators/dotnet/writers/controller_cqrs.py @@ -0,0 +1,70 @@ +"""Controller method builders for MediatR and Wolverine transports.""" + +from .controller_mapping import build_manual_mapping_methods, map_expression +from ..template_loader import render_template + + +def build_mediatr_controller_methods( + entity_name: str, + dto_name: str, + id_type: str, + id_property_name: str, + properties, + use_mapping: bool = False, +): + return build_cqrs_controller_methods( + "MediatRControllerMethods.cs.tmpl", + entity_name, + dto_name, + id_type, + id_property_name, + properties, + use_mapping, + ) + + +def build_wolverine_controller_methods( + entity_name: str, + dto_name: str, + id_type: str, + id_property_name: str, + properties, + use_mapping: bool = False, +): + return build_cqrs_controller_methods( + "WolverineControllerMethods.cs.tmpl", + entity_name, + dto_name, + id_type, + id_property_name, + properties, + use_mapping, + ) + + +def build_cqrs_controller_methods( + template_name, + entity_name, + dto_name, + id_type, + id_property_name, + properties, + use_mapping, +): + core_methods = render_template( + template_name, + { + "EntityName": entity_name, + "DtoName": dto_name, + "IdType": id_type, + "IdPropertyName": id_property_name, + "EntityToDto": map_expression("entity", dto_name, use_mapping), + "RequestToEntity": map_expression("request", entity_name, use_mapping), + "CreatedToDto": map_expression("created", dto_name, use_mapping), + }, + ) + + if use_mapping: + return core_methods + + return core_methods + build_manual_mapping_methods(entity_name, dto_name, properties) diff --git a/pse/generators/dotnet/writers/controller_mapping.py b/pse/generators/dotnet/writers/controller_mapping.py new file mode 100644 index 0000000..4279503 --- /dev/null +++ b/pse/generators/dotnet/writers/controller_mapping.py @@ -0,0 +1,32 @@ +"""Shared entity and DTO mapping code builders.""" + +from ..template_loader import render_template + + +def build_manual_mapping_methods(entity_name: str, dto_name: str, properties): + to_dto_assignments = build_object_initializer("entity", dto_name, properties) + to_entity_assignments = build_object_initializer("dto", entity_name, properties) + return render_template( + "ControllerManualMapping.cs.tmpl", + { + "EntityName": entity_name, + "DtoName": dto_name, + "ToDtoAssignments": to_dto_assignments, + "ToEntityAssignments": to_entity_assignments, + }, + ) + + +def map_expression(source_name: str, target_name: str, use_mapping: bool): + if use_mapping: + return f"{source_name}.Adapt<{target_name}>()" + if target_name.endswith("Dto"): + return f"ToDto({source_name})" + return f"ToEntity({source_name})" + + +def build_object_initializer(source_name: str, target_name: str, properties): + return "".join( + f" {prop_name} = {source_name}.{prop_name},\n" + for prop_name in properties or [] + ) diff --git a/pse/generators/dotnet/writers/controllers.py b/pse/generators/dotnet/writers/controllers.py new file mode 100644 index 0000000..f400d2d --- /dev/null +++ b/pse/generators/dotnet/writers/controllers.py @@ -0,0 +1,152 @@ +import os + +from .controller_cqrs import ( + build_mediatr_controller_methods, + build_wolverine_controller_methods, +) +from .controller_mapping import build_manual_mapping_methods, map_expression +from ..structure_helpers import build_namespace, pick_id_property_name, pick_id_type +from ..template_loader import render_template + + +def write_controller(path: str, folder: str, name: str, entity, dto_name: str, use_mapping: bool = False, cqrs_implementation: str = None): + namespace = build_namespace(path, folder) + entity_namespace = replace_surface_namespace(namespace, "Domain.Entities") + service_namespace = replace_surface_namespace(namespace, "Application.Interfaces") + cqrs_namespace = replace_surface_namespace(namespace, "Application.Cqrs") + dto_namespace = build_namespace(path, "Dtos") + id_type = pick_id_type(entity.properties) + id_property_name = pick_id_property_name(entity.properties) + cqrs_implementation = (cqrs_implementation or "").lower() + + if controller_is_current(path, entity.name, use_mapping, cqrs_implementation): + return + + methods = build_controller_methods( + entity_name=entity.name, + dto_name=dto_name, + id_type=id_type, + id_property_name=id_property_name, + properties=list(entity.properties.keys()), + use_mapping=use_mapping, + cqrs_implementation=cqrs_implementation, + ) + using_lines = build_controller_usings( + dto_namespace, + entity_namespace, + service_namespace, + cqrs_namespace, + use_mapping, + cqrs_implementation, + ) + content = render_template( + "Controller.cs.tmpl", + { + "UsingLines": f"{using_lines}\n", + "Namespace": namespace, + "ControllerName": name, + "Methods": methods, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def replace_surface_namespace(namespace: str, target: str): + for surface in ("API.Controllers", "Presentation.Controllers", "Gateway.Controllers"): + suffix = f".{surface}" + if namespace.endswith(suffix): + return f"{namespace[:-len(suffix)]}.{target}" + return namespace + + +def controller_is_current(path, entity_name, use_mapping, cqrs_implementation): + if not os.path.exists(path): + return False + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + has_expected_dependency = ( + (cqrs_implementation == "mediatr" and "IMediator" in content) + or (cqrs_implementation == "wolverine" and "IMessageBus" in content) + or ( + cqrs_implementation not in {"mediatr", "wolverine"} + and f"I{entity_name}Service" in content + ) + ) + has_expected_mapping = use_mapping or ("ToDto(" in content and "ToEntity(" in content) + return ( + "private readonly" in content + and "{{" not in content + and "TODO" not in content + and has_expected_mapping + and has_expected_dependency + ) + + +def build_controller_usings( + dto_namespace, + entity_namespace, + service_namespace, + cqrs_namespace, + use_mapping, + cqrs_implementation, +): + lines = [ + "using System.Collections.Generic;", + "using System.Linq;", + "using System.Threading.Tasks;", + f"using {dto_namespace};", + f"using {entity_namespace};", + ] + if cqrs_implementation in {"mediatr", "wolverine"}: + lines.append(f"using {cqrs_namespace};") + else: + lines.append(f"using {service_namespace};") + + if cqrs_implementation == "mediatr": + lines.append("using MediatR;") + elif cqrs_implementation == "wolverine": + lines.append("using Wolverine;") + if use_mapping: + lines.append("using Mapster;") + + return "\n".join(lines) + "\n" + + +def build_controller_methods(entity_name: str, dto_name: str, id_type: str, id_property_name: str, properties, use_mapping: bool = False, cqrs_implementation: str = None): + if cqrs_implementation == "mediatr": + return build_mediatr_controller_methods( + entity_name, dto_name, id_type, id_property_name, properties, use_mapping + ) + + if cqrs_implementation == "wolverine": + return build_wolverine_controller_methods( + entity_name, dto_name, id_type, id_property_name, properties, use_mapping + ) + + service_field_name = f"_{entity_name[0].lower()}{entity_name[1:]}Service" + service_parameter_name = f"{entity_name[0].lower()}{entity_name[1:]}Service" + service_interface_name = f"I{entity_name}Service" + core_methods = render_template( + "ServiceControllerMethods.cs.tmpl", + { + "ServiceInterface": service_interface_name, + "ServiceField": service_field_name, + "ServiceParameter": service_parameter_name, + "EntityName": entity_name, + "DtoName": dto_name, + "IdType": id_type, + "IdPropertyName": id_property_name, + "EntityToDto": map_expression("entity", dto_name, use_mapping), + "RequestToEntity": map_expression("request", entity_name, use_mapping), + "CreatedToDto": map_expression("created", dto_name, use_mapping), + }, + ) + + if use_mapping: + return core_methods + + return core_methods + build_manual_mapping_methods(entity_name, dto_name, properties) diff --git a/pse/generators/dotnet/writers/cqrs.py b/pse/generators/dotnet/writers/cqrs.py new file mode 100644 index 0000000..0c87cdd --- /dev/null +++ b/pse/generators/dotnet/writers/cqrs.py @@ -0,0 +1,38 @@ +import os + +from ..structure_helpers import build_namespace +from ..template_loader import render_template + + +def write_mediatr_cqrs_class(path: str, folder: str, entity_name: str, id_type: str): + write_cqrs_class(path, folder, entity_name, id_type, "MediatRRequests.cs.tmpl") + + +def write_wolverine_cqrs_class(path: str, folder: str, entity_name: str, id_type: str): + write_cqrs_class(path, folder, entity_name, id_type, "WolverineMessages.cs.tmpl") + + +def write_cqrs_class(path: str, folder: str, entity_name: str, id_type: str, template_name: str): + namespace = build_namespace(path, folder) + project_root = namespace.split(".")[0] + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if "Handler" in content and "{{" not in content and "TODO" not in content: + return + + content = render_template( + template_name, + { + "Namespace": namespace, + "DomainNamespace": f"{project_root}.Domain", + "InterfaceNamespace": f"{project_root}.Application.Interfaces", + "EntityName": entity_name, + "IdType": id_type, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) diff --git a/pse/generators/dotnet/writers/model.py b/pse/generators/dotnet/writers/model.py new file mode 100644 index 0000000..6fad1e0 --- /dev/null +++ b/pse/generators/dotnet/writers/model.py @@ -0,0 +1,202 @@ +import os + +from ..structure_helpers import build_namespace, build_properties +from ..template_loader import render_template + + +def write_csharp_class(path: str, folder: str, name: str, base_type: str = None, properties=None, is_interface: bool = False, additional_usings=None): + if os.path.exists(path): + return + + namespace = build_namespace(path, folder) + prop_lines, needs_system = build_properties(properties or {}) + using_names = list(additional_usings or []) + if needs_system: + using_names.insert(0, "System") + using_lines = "".join(f"using {item};\n" for item in dict.fromkeys(using_names)) + if using_lines: + using_lines += "\n" + + if is_interface: + signature = f"public interface {name}" + else: + signature = f"public class {name}" + if base_type: + signature += f" : {base_type}" + + content = render_template( + "CSharpClass.cs.tmpl", + { + "UsingLines": using_lines, + "Namespace": namespace, + "Signature": signature, + "Body": prop_lines, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def write_aggregate_class(path: str, folder: str, aggregate): + namespace = build_namespace(path, folder) + project_root = namespace.rsplit(".Aggregates", 1)[0] + child_properties = "".join( + f" public List<{child}> {child}s {{ get; }} = new();\n" + for child in aggregate.children + ) + content = render_template( + "Aggregate.cs.tmpl", + { + "EntityNamespace": f"{project_root}.Entities", + "Namespace": namespace, + "AggregateName": aggregate.name, + "RootType": aggregate.root, + "ChildProperties": child_properties, + }, + ) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + + +def write_repository_class(path: str, folder: str, name: str, interface_name: str, entity_name: str, id_type: str, id_property_name: str, use_database: bool = False): + namespace = build_namespace(path, folder) + domain_namespace = namespace.replace(".Infrastructure.Repositories", ".Domain") + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if ( + "GetAll()" in content + and "GetById(" in content + and "Create(" in content + and "NotImplementedException" not in content + and "Array.Empty" not in content + and "{{" not in content + and "Repositories.Entities" not in content + and "Repositories.Repositories" not in content + ): + return + + template_name = ( + "EfRepositoryImplementation.cs.tmpl" + if use_database + else "RepositoryImplementation.cs.tmpl" + ) + content = render_template( + template_name, + { + "DomainNamespace": domain_namespace, + "EntityName": entity_name, + "IdType": id_type, + "IdPropertyName": id_property_name, + "Namespace": namespace, + "ClassName": name, + "InterfaceName": interface_name, + "PersistenceNamespace": namespace.replace(".Repositories", ".Persistence"), + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def write_service_interface(path: str, folder: str, name: str, entity_name: str, id_type: str): + namespace = build_namespace(path, folder) + domain_namespace = namespace.replace(".Application.Interfaces", ".Domain") + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if ( + "GetAll()" in content + and "GetById(" in content + and "Create(" in content + and "bool Update(" in content + and "bool Delete(" in content + and "{{" not in content + ): + return + + content = render_template( + "ApplicationServiceInterface.cs.tmpl", + { + "DomainNamespace": domain_namespace, + "Namespace": namespace, + "InterfaceName": name, + "EntityName": entity_name, + "IdType": id_type, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def write_service_class(path: str, folder: str, name: str, interface_name: str, entity_name: str, id_type: str, id_property_name: str): + namespace = build_namespace(path, folder) + domain_namespace = namespace.replace(".Application.Services", ".Domain") + interface_namespace = namespace.replace(".Services", ".Interfaces") + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if ( + "GetAll()" in content + and "GetById(" in content + and "Create(" in content + and "bool Update(" in content + and "bool Delete(" in content + and "{{" not in content + and "TODO" not in content + ): + return + + content = render_template( + "ApplicationService.cs.tmpl", + { + "DomainNamespace": domain_namespace, + "InterfaceNamespace": interface_namespace, + "Namespace": namespace, + "ClassName": name, + "InterfaceName": interface_name, + "EntityName": entity_name, + "IdType": id_type, + "IdPropertyName": id_property_name, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def write_repository_interface(path: str, folder: str, name: str, entity_name: str, id_type: str): + namespace = build_namespace(path, folder) + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + if ( + "GetAll()" in content + and "GetById(" in content + and "Create(" in content + and "{{" not in content + ): + return + + content = render_template( + "RepositoryInterface.cs.tmpl", + { + "DomainNamespace": namespace.replace(".Repositories", ""), + "Namespace": namespace, + "InterfaceName": name, + "EntityName": entity_name, + "IdType": id_type, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) diff --git a/pse/generators/dotnet/writers/tests.py b/pse/generators/dotnet/writers/tests.py new file mode 100644 index 0000000..4e7577a --- /dev/null +++ b/pse/generators/dotnet/writers/tests.py @@ -0,0 +1,78 @@ +from ..structure_helpers import build_namespace, map_type +from ..template_loader import render_template + + +def write_test_class(path: str, folder: str, name: str, entity): + namespace = build_namespace(path, folder) + project_root = namespace.split(".")[0] + subject_name = entity.name + body = build_entity_test_body(entity) + content = render_template( + "TestClass.cs.tmpl", + { + "UsingLines": f"using System;\nusing {project_root}.Domain.Entities;\n", + "Namespace": namespace, + "ClassName": name, + "TestMethodName": f"CanCreate{subject_name}", + "Body": body, + }, + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def build_entity_test_body(entity): + assignments = [] + assertions = [] + + for prop_name, prop_type in (entity.properties or {}).items(): + cs_type, _ = map_type(prop_type) + value = sample_value(prop_name, cs_type) + assignments.append(f" {prop_name} = {value},\n") + assertions.append(assertion_for_property(prop_name, cs_type, value)) + + if not assignments: + return f" var entity = new {entity.name}();\n\n Assert.NotNull(entity);\n" + + return ( + f" var entity = new {entity.name}\n" + " {\n" + f"{''.join(assignments)}" + " };\n\n" + f"{''.join(assertions)}" + ) + + +def sample_value(prop_name: str, cs_type: str): + if cs_type == "Guid": + return "Guid.NewGuid()" + if cs_type == "DateTime": + return "DateTime.UtcNow" + if cs_type == "string": + return f"\"{prop_name}\"" + if cs_type == "bool": + return "true" + if cs_type in {"int", "long"}: + return "1" + if cs_type == "decimal": + return "1m" + if cs_type == "double": + return "1d" + if cs_type == "float": + return "1f" + + return f"new {cs_type}()" + + +def assertion_for_property(prop_name: str, cs_type: str, value: str): + if cs_type == "Guid": + return f" Assert.NotEqual(Guid.Empty, entity.{prop_name});\n" + if cs_type == "DateTime": + return f" Assert.NotEqual(default, entity.{prop_name});\n" + if cs_type == "string": + return f" Assert.Equal({value}, entity.{prop_name});\n" + if cs_type == "bool": + return f" Assert.True(entity.{prop_name});\n" + + return f" Assert.Equal({value}, entity.{prop_name});\n" diff --git a/pse/heuristics/archetypes.yaml b/pse/heuristics/archetypes.yaml index fa733bd..ce1fc4c 100644 --- a/pse/heuristics/archetypes.yaml +++ b/pse/heuristics/archetypes.yaml @@ -1,6 +1,6 @@ webapi: projects: - - Presentation + - API - Application - Domain - Infrastructure @@ -12,8 +12,3 @@ cleanarchitecture: - Application - Domain - Infrastructure - -modularmonolith: - projects: - - SharedKernel - - Modules diff --git a/pse/heuristics/packages.yaml b/pse/heuristics/packages.yaml index eb4400c..1de394e 100644 --- a/pse/heuristics/packages.yaml +++ b/pse/heuristics/packages.yaml @@ -42,13 +42,15 @@ postgres: version: efcore - name: Npgsql.EntityFrameworkCore.PostgreSQL version: npgsql_efcore + - name: Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore + version: efcore redis: packages: - name: StackExchange.Redis version: stackexchange_redis - name: Microsoft.Extensions.Caching.StackExchangeRedis - version: efcore + version: microsoft_extensions rabbitmq: packages: diff --git a/pse/heuristics/versions.yaml b/pse/heuristics/versions.yaml index 8d16238..f075a3b 100644 --- a/pse/heuristics/versions.yaml +++ b/pse/heuristics/versions.yaml @@ -30,4 +30,6 @@ npgsql_efcore: "10.0.2" stackexchange_redis: "3.0.11" +microsoft_extensions: "10.0.9" + mass_transit: "9.0.0" diff --git a/pse/model/dependency_graph.py b/pse/model/dependency_graph.py index b9f51f1..0f0e618 100644 --- a/pse/model/dependency_graph.py +++ b/pse/model/dependency_graph.py @@ -15,23 +15,24 @@ def add(self, node: str, depends_on: List[str]): def topological_sort(self): visited = set() - visiting = set() + visiting = [] result = [] def visit(n): if n in visiting: - cycle = " -> ".join([*visiting, n]) + cycle_start = visiting.index(n) + cycle = " -> ".join([*visiting[cycle_start:], n]) raise DependencyCycleError(f"Dependency cycle detected: {cycle}") if n in visited: return - visiting.add(n) + visiting.append(n) for dep in self.nodes.get(n, []): visit(dep) - visiting.remove(n) + visiting.pop() visited.add(n) result.append(n) diff --git a/pse/run.py b/pse/run.py index 4f44368..c9f0093 100644 --- a/pse/run.py +++ b/pse/run.py @@ -12,9 +12,14 @@ def main(argv=None): parser = argparse.ArgumentParser(description="Run PSE against a DSL file.") parser.add_argument("input", nargs="?", default="sample.pse", help="Path to the DSL file") parser.add_argument("-o", "--output", default="./sample_output", help="Output directory") + parser.add_argument( + "--no-overwrite", + action="store_true", + help="Preserve modified generated files and only refresh unchanged owned files.", + ) args = parser.parse_args(argv) - return 0 if run_pse(args.input, args.output) else 1 + return 0 if run_pse(args.input, args.output, overwrite=not args.no_overwrite) else 1 if __name__ == "__main__": diff --git a/pse/template_loader.py b/pse/template_loader.py new file mode 100644 index 0000000..b39604a --- /dev/null +++ b/pse/template_loader.py @@ -0,0 +1,21 @@ +import os +import re + + +TEMPLATE_ROOT = os.path.join(os.path.dirname(__file__), "templates") + + +def render_template(relative_path: str, values=None): + path = os.path.join(TEMPLATE_ROOT, relative_path) + with open(path, "r", encoding="utf-8") as handle: + content = handle.read() + + for key, value in (values or {}).items(): + content = content.replace("{{" + key + "}}", str(value)) + + unresolved = sorted(set(re.findall(r"\{\{([A-Za-z][A-Za-z0-9_]*)\}\}", content))) + if unresolved: + names = ", ".join(unresolved) + raise ValueError(f"Template '{relative_path}' has unresolved placeholders: {names}") + + return content diff --git a/pse/templates/deployment/kubernetes-readme.md.tmpl b/pse/templates/deployment/kubernetes-readme.md.tmpl new file mode 100644 index 0000000..dd772fe --- /dev/null +++ b/pse/templates/deployment/kubernetes-readme.md.tmpl @@ -0,0 +1,11 @@ +# Kubernetes deployment + +Build and push `{{Image}}`, then update the image in `manifest.yaml`. + +`secret.example.yaml` is intentionally not part of the main manifest. Use it only as a key reference; create the real Secret through your platform secret manager. + +```sh +kubectl apply -f deploy/kubernetes/namespace.yaml +{{SecretSetup}}kubectl apply -f deploy/kubernetes/manifest.yaml +kubectl -n {{AppName}} rollout status deployment/{{AppName}} +``` diff --git a/pse/templates/deployment/swarm-readme.md.tmpl b/pse/templates/deployment/swarm-readme.md.tmpl new file mode 100644 index 0000000..4789444 --- /dev/null +++ b/pse/templates/deployment/swarm-readme.md.tmpl @@ -0,0 +1,15 @@ +# Docker Swarm deployment + +Build and push `{{Image}}` to a registry reachable by every Swarm node, then update the image value in `stack.yml`. + +Create the external secrets once: + +```sh +{{SecretCommands}} +``` + +Deploy the stack: + +```sh +docker stack deploy --with-registry-auth -c deploy/swarm/stack.yml {{AppName}} +``` diff --git a/pse/templates/dotnet/Aggregate.cs.tmpl b/pse/templates/dotnet/Aggregate.cs.tmpl new file mode 100644 index 0000000..06ce224 --- /dev/null +++ b/pse/templates/dotnet/Aggregate.cs.tmpl @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using {{EntityNamespace}}; + +namespace {{Namespace}}; + +public class {{AggregateName}} +{ + public {{RootType}} Root { get; } +{{ChildProperties}} + public {{AggregateName}}({{RootType}} root) + { + Root = root; + } +} diff --git a/pse/templates/dotnet/AppSettings.Development.json.tmpl b/pse/templates/dotnet/AppSettings.Development.json.tmpl index ed0b443..2c11658 100644 --- a/pse/templates/dotnet/AppSettings.Development.json.tmpl +++ b/pse/templates/dotnet/AppSettings.Development.json.tmpl @@ -1,5 +1,5 @@ { -{{ConnectionStringsBlock}}{{InfraSections}} +{{ConnectionStringsBlock}} "Logging": { "LogLevel": { "Default": "Debug", diff --git a/pse/templates/dotnet/AppSettings.json.tmpl b/pse/templates/dotnet/AppSettings.json.tmpl index 1ed76a4..2e76cd5 100644 --- a/pse/templates/dotnet/AppSettings.json.tmpl +++ b/pse/templates/dotnet/AppSettings.json.tmpl @@ -1,5 +1,5 @@ { -{{ConnectionStringsBlock}}{{InfraSections}} +{{ConnectionStringsBlock}} "Logging": { "LogLevel": { "Default": "Information", diff --git a/pse/templates/dotnet/ControllerManualMapping.cs.tmpl b/pse/templates/dotnet/ControllerManualMapping.cs.tmpl new file mode 100644 index 0000000..e58e39b --- /dev/null +++ b/pse/templates/dotnet/ControllerManualMapping.cs.tmpl @@ -0,0 +1,14 @@ + + private static {{DtoName}} ToDto({{EntityName}} entity) + { + return new {{DtoName}} + { +{{ToDtoAssignments}} }; + } + + private static {{EntityName}} ToEntity({{DtoName}} dto) + { + return new {{EntityName}} + { +{{ToEntityAssignments}} }; + } diff --git a/pse/templates/dotnet/ControllerMethods.cs.tmpl b/pse/templates/dotnet/ControllerMethods.cs.tmpl deleted file mode 100644 index 316110b..0000000 --- a/pse/templates/dotnet/ControllerMethods.cs.tmpl +++ /dev/null @@ -1,17 +0,0 @@ - [HttpGet] - public ActionResult> GetAll() - { - return Ok(new List<{{DtoType}}>()); - } - - [HttpGet("{id}")] - public ActionResult<{{DtoType}}> GetById({{IdType}} id) - { - return Ok(new {{DtoType}}()); - } - - [HttpPost] - public ActionResult<{{DtoType}}> Create({{DtoType}} request) - { - return CreatedAtAction(nameof(GetById), new { id = request.{{IdentifierPropertyName}} }, request); - } diff --git a/pse/templates/dotnet/DbContext.cs.tmpl b/pse/templates/dotnet/DbContext.cs.tmpl index 9e99a90..86a65da 100644 --- a/pse/templates/dotnet/DbContext.cs.tmpl +++ b/pse/templates/dotnet/DbContext.cs.tmpl @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using {{DomainNamespace}}.Entities; namespace {{Namespace}}; @@ -8,4 +9,9 @@ public class {{ClassName}} : DbContext : base(options) { } + +{{DbSets}} + protected override void OnModelCreating(ModelBuilder modelBuilder) + { +{{KeyConfigurations}} } } diff --git a/pse/templates/dotnet/Dockerfile.tmpl b/pse/templates/dotnet/Dockerfile.tmpl index 6ab57f1..309d824 100644 --- a/pse/templates/dotnet/Dockerfile.tmpl +++ b/pse/templates/dotnet/Dockerfile.tmpl @@ -1,6 +1,24 @@ -FROM mcr.microsoft.com/dotnet/aspnet:{{DotnetVersion}} +FROM mcr.microsoft.com/dotnet/sdk:{{DotnetVersion}} AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY . . +RUN dotnet restore "{{EntrypointProject}}/{{EntrypointProject}}.csproj" +RUN dotnet publish "{{EntrypointProject}}/{{EntrypointProject}}.csproj" \ + --configuration $BUILD_CONFIGURATION \ + --output /app/publish \ + --no-restore \ + /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:{{DotnetVersion}} AS runtime +USER root +RUN apt-get update \ + && apt-get install --yes --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 -COPY . . -ENTRYPOINT ["dotnet", "{{ProjectName}}.API.dll"] +COPY --from=build /app/publish . +USER $APP_UID +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl --fail --silent http://localhost:8080/health/live || exit 1 +ENTRYPOINT ["dotnet", "{{EntrypointProject}}.dll"] diff --git a/pse/templates/dotnet/EfRepositoryImplementation.cs.tmpl b/pse/templates/dotnet/EfRepositoryImplementation.cs.tmpl new file mode 100644 index 0000000..7fb9cce --- /dev/null +++ b/pse/templates/dotnet/EfRepositoryImplementation.cs.tmpl @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using {{DomainNamespace}}.Entities; +using {{DomainNamespace}}.Repositories; +using {{PersistenceNamespace}}; + +namespace {{Namespace}}; + +public class {{ClassName}} : {{InterfaceName}} +{ + private readonly AppDbContext _dbContext; + private readonly DbSet<{{EntityName}}> _entities; + + public {{ClassName}}(AppDbContext dbContext) + { + _dbContext = dbContext; + _entities = dbContext.Set<{{EntityName}}>(); + } + + public IEnumerable<{{EntityName}}> GetAll() + { + return _entities.AsNoTracking().ToList(); + } + + public {{EntityName}}? GetById({{IdType}} id) + { + return _entities.Find(id); + } + + public void Create({{EntityName}} entity) + { + _entities.Add(entity); + _dbContext.SaveChanges(); + } + + public void Update({{EntityName}} entity) + { + _entities.Update(entity); + _dbContext.SaveChanges(); + } + + public void Delete({{IdType}} id) + { + var entity = _entities.Find(id); + if (entity is null) + { + return; + } + + _entities.Remove(entity); + _dbContext.SaveChanges(); + } +} diff --git a/pse/templates/dotnet/MediatRControllerMethods.cs.tmpl b/pse/templates/dotnet/MediatRControllerMethods.cs.tmpl new file mode 100644 index 0000000..f17bae2 --- /dev/null +++ b/pse/templates/dotnet/MediatRControllerMethods.cs.tmpl @@ -0,0 +1,59 @@ + private readonly IMediator _mediator; + + public {{EntityName}}Controller(IMediator mediator) + { + _mediator = mediator; + } + + [HttpGet] + public async Task>> GetAll() + { + var entities = await _mediator.Send(new GetAll{{EntityName}}Query()); + var response = entities.Select(entity => {{EntityToDto}}).ToList(); + return Ok(response); + } + + [HttpGet("{id}")] + public async Task> GetById({{IdType}} id) + { + var entity = await _mediator.Send(new Get{{EntityName}}ByIdQuery(id)); + if (entity is null) + { + return NotFound(); + } + + return Ok({{EntityToDto}}); + } + + [HttpPost] + public async Task> Create({{DtoName}} request) + { + var entity = {{RequestToEntity}}; + var created = await _mediator.Send(new Create{{EntityName}}Command(entity)); + return CreatedAtAction(nameof(GetById), new { id = created.{{IdPropertyName}} }, {{CreatedToDto}}); + } + + [HttpPut("{id}")] + public async Task Update({{IdType}} id, {{DtoName}} request) + { + var entity = {{RequestToEntity}}; + var updated = await _mediator.Send(new Update{{EntityName}}Command(id, entity)); + if (!updated) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task Delete({{IdType}} id) + { + var deleted = await _mediator.Send(new Delete{{EntityName}}Command(id)); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } diff --git a/pse/templates/dotnet/OptionsClass.cs.tmpl b/pse/templates/dotnet/OptionsClass.cs.tmpl deleted file mode 100644 index 9fdd4be..0000000 --- a/pse/templates/dotnet/OptionsClass.cs.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -namespace {{Namespace}}; - -public class {{ClassName}} -{ -{{Body}}} diff --git a/pse/templates/dotnet/Program.cs.tmpl b/pse/templates/dotnet/Program.cs.tmpl index 67d870b..6ef78eb 100644 --- a/pse/templates/dotnet/Program.cs.tmpl +++ b/pse/templates/dotnet/Program.cs.tmpl @@ -1,10 +1,21 @@ {{UsingLines}}var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddKeyPerFile("/run/secrets", optional: true); + {{HostConfiguration}} builder.Services.AddControllers(); +{{HealthChecks}} {{ServiceRegistrations}}{{InfraRegistrations}}var app = builder.Build(); {{InfraPipeline}}app.MapControllers(); +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("live") +}); +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = _ => true +}); app.Run(); diff --git a/pse/templates/dotnet/Repository.cs.tmpl b/pse/templates/dotnet/Repository.cs.tmpl deleted file mode 100644 index f3469fb..0000000 --- a/pse/templates/dotnet/Repository.cs.tmpl +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using {{DomainNamespace}}.Entities; -using {{DomainNamespace}}.Repositories; - -namespace {{Namespace}}; - -public class {{ClassName}} : {{InterfaceName}} -{ - private readonly List<{{EntityName}}> _items = new(); - - public IEnumerable<{{EntityName}}> GetAll() - { - return _items; - } - - public {{EntityName}}? GetById({{IdType}} id) - { - return _items.FirstOrDefault(entity => - EqualityComparer<{{IdType}}>.Default.Equals(entity.{{IdPropertyName}}, id)); - } - - public void Create({{EntityName}} entity) - { - if (GetById(entity.{{IdPropertyName}}) is not null) - { - Update(entity); - return; - } - - _items.Add(entity); - } - - public void Update({{EntityName}} entity) - { - var index = _items.FindIndex(existing => - EqualityComparer<{{IdType}}>.Default.Equals(existing.{{IdPropertyName}}, entity.{{IdPropertyName}})); - - if (index >= 0) - { - _items[index] = entity; - return; - } - - _items.Add(entity); - } - - public void Delete({{IdType}} id) - { - var entity = GetById(id); - if (entity is not null) - { - _items.Remove(entity); - } - } -} diff --git a/pse/templates/dotnet/RepositoryImplementation.cs.tmpl b/pse/templates/dotnet/RepositoryImplementation.cs.tmpl index cb2e577..c60ce09 100644 --- a/pse/templates/dotnet/RepositoryImplementation.cs.tmpl +++ b/pse/templates/dotnet/RepositoryImplementation.cs.tmpl @@ -1,6 +1,5 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using {{DomainNamespace}}.Entities; using {{DomainNamespace}}.Repositories; @@ -8,50 +7,30 @@ namespace {{Namespace}}; public class {{ClassName}} : {{InterfaceName}} { - private readonly List<{{EntityName}}> _items = new(); + private readonly ConcurrentDictionary<{{IdType}}, {{EntityName}}> _items = new(); public IEnumerable<{{EntityName}}> GetAll() { - return _items; + return _items.Values; } public {{EntityName}}? GetById({{IdType}} id) { - return _items.FirstOrDefault(entity => - EqualityComparer<{{IdType}}>.Default.Equals(entity.{{IdPropertyName}}, id)); + return _items.GetValueOrDefault(id); } public void Create({{EntityName}} entity) { - if (GetById(entity.{{IdPropertyName}}) is not null) - { - Update(entity); - return; - } - - _items.Add(entity); + _items[entity.{{IdPropertyName}}] = entity; } public void Update({{EntityName}} entity) { - var index = _items.FindIndex(existing => - EqualityComparer<{{IdType}}>.Default.Equals(existing.{{IdPropertyName}}, entity.{{IdPropertyName}})); - - if (index >= 0) - { - _items[index] = entity; - return; - } - - _items.Add(entity); + _items[entity.{{IdPropertyName}}] = entity; } public void Delete({{IdType}} id) { - var entity = GetById(id); - if (entity is not null) - { - _items.Remove(entity); - } + _items.TryRemove(id, out _); } } diff --git a/pse/templates/dotnet/ServiceControllerMethods.cs.tmpl b/pse/templates/dotnet/ServiceControllerMethods.cs.tmpl new file mode 100644 index 0000000..8882153 --- /dev/null +++ b/pse/templates/dotnet/ServiceControllerMethods.cs.tmpl @@ -0,0 +1,56 @@ + private readonly {{ServiceInterface}} {{ServiceField}}; + + public {{EntityName}}Controller({{ServiceInterface}} {{ServiceParameter}}) + { + {{ServiceField}} = {{ServiceParameter}}; + } + + [HttpGet] + public ActionResult> GetAll() + { + var entities = {{ServiceField}}.GetAll().Select(entity => {{EntityToDto}}).ToList(); + return Ok(entities); + } + + [HttpGet("{id}")] + public ActionResult<{{DtoName}}> GetById({{IdType}} id) + { + var entity = {{ServiceField}}.GetById(id); + if (entity is null) + { + return NotFound(); + } + + return Ok({{EntityToDto}}); + } + + [HttpPost] + public ActionResult<{{DtoName}}> Create({{DtoName}} request) + { + var entity = {{RequestToEntity}}; + var created = {{ServiceField}}.Create(entity); + return CreatedAtAction(nameof(GetById), new { id = created.{{IdPropertyName}} }, {{CreatedToDto}}); + } + + [HttpPut("{id}")] + public IActionResult Update({{IdType}} id, {{DtoName}} request) + { + var entity = {{RequestToEntity}}; + if (!{{ServiceField}}.Update(id, entity)) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult Delete({{IdType}} id) + { + if (!{{ServiceField}}.Delete(id)) + { + return NotFound(); + } + + return NoContent(); + } diff --git a/pse/templates/dotnet/WolverineControllerMethods.cs.tmpl b/pse/templates/dotnet/WolverineControllerMethods.cs.tmpl new file mode 100644 index 0000000..f297d7f --- /dev/null +++ b/pse/templates/dotnet/WolverineControllerMethods.cs.tmpl @@ -0,0 +1,59 @@ + private readonly IMessageBus _messageBus; + + public {{EntityName}}Controller(IMessageBus messageBus) + { + _messageBus = messageBus; + } + + [HttpGet] + public async Task>> GetAll() + { + var entities = await _messageBus.InvokeAsync>(new GetAll{{EntityName}}Query()); + var response = entities.Select(entity => {{EntityToDto}}).ToList(); + return Ok(response); + } + + [HttpGet("{id}")] + public async Task> GetById({{IdType}} id) + { + var entity = await _messageBus.InvokeAsync<{{EntityName}}?>(new Get{{EntityName}}ByIdQuery(id)); + if (entity is null) + { + return NotFound(); + } + + return Ok({{EntityToDto}}); + } + + [HttpPost] + public async Task> Create({{DtoName}} request) + { + var entity = {{RequestToEntity}}; + var created = await _messageBus.InvokeAsync<{{EntityName}}>(new Create{{EntityName}}Command(entity)); + return CreatedAtAction(nameof(GetById), new { id = created.{{IdPropertyName}} }, {{CreatedToDto}}); + } + + [HttpPut("{id}")] + public async Task Update({{IdType}} id, {{DtoName}} request) + { + var entity = {{RequestToEntity}}; + var updated = await _messageBus.InvokeAsync(new Update{{EntityName}}Command(id, entity)); + if (!updated) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task Delete({{IdType}} id) + { + var deleted = await _messageBus.InvokeAsync(new Delete{{EntityName}}Command(id)); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } diff --git a/pse/templates/dotnet/docker-compose.yml.tmpl b/pse/templates/dotnet/docker-compose.yml.tmpl deleted file mode 100644 index a917f1e..0000000 --- a/pse/templates/dotnet/docker-compose.yml.tmpl +++ /dev/null @@ -1,4 +0,0 @@ -version: '3.9' -services: -{{Services}} -{{Dependencies}} diff --git a/pse/templates/editor/extension.js.tmpl b/pse/templates/editor/extension.js.tmpl new file mode 100644 index 0000000..4a84239 --- /dev/null +++ b/pse/templates/editor/extension.js.tmpl @@ -0,0 +1,123 @@ +const vscode = require('vscode'); + +const KEYWORDS = {{Keywords}}; +const PRIMITIVE_TYPES = {{PrimitiveTypes}}; +const VALUES = {{Values}}; + +function item(label, kind, detail) { + const completion = new vscode.CompletionItem(label, kind); + completion.detail = detail; + return completion; +} + +function items(labels, kind, detail) { + return labels.map((label) => item(label, kind, detail)); +} + +function provideCompletionItems(document, position) { + const linePrefix = document.lineAt(position).text.slice(0, position.character); + + if (/\btarget\s*=\s*$/.test(linePrefix)) { + return items(['dotnet', 'java'], vscode.CompletionItemKind.Value, 'PSE target'); + } + + if (/^\s*(Archetype|Database|Cache|MessageBroker|Deployment|Capability)\s+$/.test(linePrefix)) { + return items(VALUES, vscode.CompletionItemKind.Value, 'PSE value'); + } + + if (/^\s*Capability\s+\w+\s*=\s*$/.test(linePrefix)) { + return items(['MediatR', 'Wolverine'], vscode.CompletionItemKind.Value, 'PSE capability implementation'); + } + + return [ + ...items(KEYWORDS, vscode.CompletionItemKind.Keyword, 'PSE keyword'), + ...items(PRIMITIVE_TYPES, vscode.CompletionItemKind.TypeParameter, 'PSE primitive type'), + ...items(VALUES, vscode.CompletionItemKind.Value, 'PSE value'), + ]; +} + +function formatPseText(text) { + const newline = text.includes('\r\n') ? '\r\n' : '\n'; + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const hadFinalNewline = normalized.endsWith('\n'); + const lines = normalized.split('\n'); + if (hadFinalNewline) { + lines.pop(); + } + + const formatted = []; + let indent = 0; + let previousWasBlank = false; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + + if (trimmed.length === 0) { + if (!previousWasBlank) { + formatted.push(''); + } + previousWasBlank = true; + continue; + } + + previousWasBlank = false; + const startsWithClose = trimmed.startsWith('}'); + if (startsWithClose) { + indent = Math.max(indent - 1, 0); + } + + formatted.push(`${' '.repeat(indent * 4)}${trimmed}`); + + const openCount = (trimmed.match(/\{/g) || []).length; + const closeCount = (trimmed.match(/\}/g) || []).length; + indent = Math.max(indent + openCount - closeCount + (startsWithClose ? 1 : 0), 0); + } + + return formatted.join(newline) + (hadFinalNewline ? newline : ''); +} + +function provideDocumentFormattingEdits(document) { + const text = document.getText(); + const formatted = formatPseText(text); + + if (formatted === text) { + return []; + } + + const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(text.length)); + return [vscode.TextEdit.replace(fullRange, formatted)]; +} + +/** + * @param {vscode.ExtensionContext} context + */ +function activate(context) { + const textxExtension = vscode.extensions.getExtension('textX.textX'); + if (textxExtension && !textxExtension.isActive) { + textxExtension.activate(); + } + + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + 'pse', + { provideCompletionItems }, + ' ', + '=', + '\t' + ) + ); + + context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider( + 'pse', + { provideDocumentFormattingEdits } + ) + ); +} + +function deactivate() {} + +module.exports = { + activate, + deactivate, +}; diff --git a/pse/textx_generators.py b/pse/textx_generators.py index a370204..8a64ecf 100644 --- a/pse/textx_generators.py +++ b/pse/textx_generators.py @@ -33,6 +33,7 @@ def pse_dotnet_generator(metamodel, model, output_path, overwrite, debug): presets=heuristics["presets"], packages=heuristics["packages"], versions=heuristics["versions"], + options={"overwrite": overwrite}, ) ctx.capabilities = resolve_capabilities(ctx) diff --git a/pse/validation.py b/pse/validation.py index 2928892..e72845b 100644 --- a/pse/validation.py +++ b/pse/validation.py @@ -8,6 +8,15 @@ BASE_DIR = os.path.dirname(__file__) HEURISTICS_DIR = os.path.join(BASE_DIR, "heuristics") SUPPORTED_TARGETS = {"dotnet"} +SUPPORTED_DEPLOYMENTS = { + "compose": "Docker", + "docker": "Docker", + "dockercompose": "Docker", + "dockerswarm": "DockerSwarm", + "k8s": "Kubernetes", + "kubernetes": "Kubernetes", + "swarm": "DockerSwarm", +} @dataclass(frozen=True) @@ -38,6 +47,7 @@ def validate_model(model, source_text: str = None): errors.extend(validate_project(model)) errors.extend(validate_contexts(model, source_text)) errors.extend(validate_infrastructure(model)) + errors.extend(validate_deployment(model, source_text)) errors.extend(validate_capabilities(model, source_text)) if errors: @@ -73,6 +83,7 @@ def validate_contexts(model, source_text: str = None): errors = [] context_names = [] known_types = collect_known_types(model) + errors.extend(validate_global_domain_names(model, source_text)) for context in getattr(model, "contexts", []) or []: context_name = context.name @@ -93,6 +104,27 @@ def validate_contexts(model, source_text: str = None): errors.extend(validate_named_collection(context_name, "aggregate", context.aggregates, source_text)) errors.extend(validate_aggregates(context_name, context.entities, context.aggregates, source_text)) errors.extend(validate_property_types(context_name, context.entities, context.valueObjects, known_types, source_text)) + for item_label, items in ( + ("entity", context.entities), + ("value object", context.valueObjects), + ): + for item in items or []: + if not (item.properties or []): + errors.append( + problem_from_node( + f"{item_label.title()} '{item.name}' in context '{context_name}' must define at least one property.", + item, + source_text, + ) + ) + errors.extend( + validate_named_collection( + f"{context_name}.{item.name}", + f"{item_label} property", + item.properties, + source_text, + ) + ) return errors @@ -109,9 +141,83 @@ def validate_infrastructure(model): if item and not getattr(item, "type", None): errors.append(ValidationProblem(f"Infrastructure {label} must specify a type.")) + supported = { + "database": {"postgres", "postgresql"}, + "cache": {"redis"}, + "broker": {"rabbitmq"}, + } + for label, implementations in supported.items(): + item = getattr(infra, label, None) + value = normalize_name(getattr(item, "type", None)) + if value and value not in implementations: + errors.append( + ValidationProblem( + f"Infrastructure {label} '{item.type}' is not supported. " + f"Supported values: {', '.join(sorted(implementations))}." + ) + ) + + return errors + + +def validate_global_domain_names(model, source_text: str = None): + errors = [] + seen_types = {} + seen_aggregates = {} + + for context in getattr(model, "contexts", []) or []: + for item in [*(context.entities or []), *(context.valueObjects or [])]: + normalized = normalize_name(item.name) + previous = seen_types.get(normalized) + if previous: + errors.append( + problem_from_node( + f"Domain type '{item.name}' in context '{context.name}' conflicts with " + f"'{previous[1]}' in context '{previous[0]}'. Generated .NET type names must be globally unique.", + item, + source_text, + ) + ) + else: + seen_types[normalized] = (context.name, item.name) + + for aggregate in context.aggregates or []: + normalized = normalize_name(aggregate.name) + previous = seen_aggregates.get(normalized) + if previous: + errors.append( + problem_from_node( + f"Aggregate '{aggregate.name}' in context '{context.name}' conflicts with " + f"aggregate '{previous[1]}' in context '{previous[0]}'.", + aggregate, + source_text, + ) + ) + else: + seen_aggregates[normalized] = (context.name, aggregate.name) + return errors +def validate_deployment(model, source_text: str = None): + deployment = getattr(model, "deployment", None) + if not deployment: + return [] + + target = getattr(deployment, "target", None) + if normalize_name(target) in SUPPORTED_DEPLOYMENTS: + return [] + + supported = ", ".join(sorted(set(SUPPORTED_DEPLOYMENTS.values()))) + return [ + problem_from_node( + f"Deployment target '{target}' is not supported. Supported targets: {supported}.", + deployment, + source_text, + ) + ] + + def validate_capabilities(model, source_text: str = None): capabilities = getattr(model, "capabilities", None) explicit = getattr(capabilities, "capabilities", []) or [] @@ -121,9 +227,21 @@ def validate_capabilities(model, source_text: str = None): registry = load_capability_registry() available = {normalize_name(name) for name in registry.keys()} errors = [] + seen = set() for capability in explicit: name = normalize_name(capability.name) + if name in seen: + errors.append( + problem_from_node( + f"Capability '{capability.name}' is declared more than once.", + capability, + source_text, + ) + ) + continue + seen.add(name) + if name not in available: errors.append( problem_from_node( diff --git a/pse/vscode.py b/pse/vscode.py index 2212adc..f274dcf 100644 --- a/pse/vscode.py +++ b/pse/vscode.py @@ -8,6 +8,7 @@ import zipfile from pse.language import pse_language +from pse.template_loader import render_template KEYWORD_COMPLETIONS = [ @@ -47,12 +48,13 @@ "java", "WebApi", "CleanArchitecture", - "ModularMonolith", - "Microservices", "PostgreSQL", "Redis", "RabbitMQ", "Docker", + "DockerSwarm", + "Kubernetes", + "K8s", "Logging", "Validation", "Mapping", @@ -100,134 +102,14 @@ def build_command(output_path: str, overwrite: bool = True): def completion_extension_js(): - keywords = json.dumps(KEYWORD_COMPLETIONS, indent=2) - primitive_types = json.dumps(PRIMITIVE_TYPE_COMPLETIONS, indent=2) - values = json.dumps(VALUE_COMPLETIONS, indent=2) - - return f"""const vscode = require('vscode'); - -const KEYWORDS = {keywords}; -const PRIMITIVE_TYPES = {primitive_types}; -const VALUES = {values}; - -function item(label, kind, detail) {{ - const completion = new vscode.CompletionItem(label, kind); - completion.detail = detail; - return completion; -}} - -function items(labels, kind, detail) {{ - return labels.map((label) => item(label, kind, detail)); -}} - -function provideCompletionItems(document, position) {{ - const linePrefix = document.lineAt(position).text.slice(0, position.character); - - if (/\\btarget\\s*=\\s*$/.test(linePrefix)) {{ - return items(['dotnet', 'java'], vscode.CompletionItemKind.Value, 'PSE target'); - }} - - if (/^\\s*(Archetype|Database|Cache|MessageBroker|Deployment|Capability)\\s+$/.test(linePrefix)) {{ - return items(VALUES, vscode.CompletionItemKind.Value, 'PSE value'); - }} - - if (/^\\s*Capability\\s+\\w+\\s*=\\s*$/.test(linePrefix)) {{ - return items(['MediatR', 'Wolverine'], vscode.CompletionItemKind.Value, 'PSE capability implementation'); - }} - - return [ - ...items(KEYWORDS, vscode.CompletionItemKind.Keyword, 'PSE keyword'), - ...items(PRIMITIVE_TYPES, vscode.CompletionItemKind.TypeParameter, 'PSE primitive type'), - ...items(VALUES, vscode.CompletionItemKind.Value, 'PSE value'), - ]; -}} - -function formatPseText(text) {{ - const newline = text.includes('\\r\\n') ? '\\r\\n' : '\\n'; - const normalized = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n'); - const hadFinalNewline = normalized.endsWith('\\n'); - const lines = normalized.split('\\n'); - if (hadFinalNewline) {{ - lines.pop(); - }} - - const formatted = []; - let indent = 0; - let previousWasBlank = false; - - for (const rawLine of lines) {{ - const trimmed = rawLine.trim(); - - if (trimmed.length === 0) {{ - if (!previousWasBlank) {{ - formatted.push(''); - }} - previousWasBlank = true; - continue; - }} - - previousWasBlank = false; - const startsWithClose = trimmed.startsWith('}}'); - if (startsWithClose) {{ - indent = Math.max(indent - 1, 0); - }} - - formatted.push(`${{' '.repeat(indent * 4)}}${{trimmed}}`); - - const openCount = (trimmed.match(/\\{{/g) || []).length; - const closeCount = (trimmed.match(/\\}}/g) || []).length; - indent = Math.max(indent + openCount - closeCount + (startsWithClose ? 1 : 0), 0); - }} - - return formatted.join(newline) + (hadFinalNewline ? newline : ''); -}} - -function provideDocumentFormattingEdits(document) {{ - const text = document.getText(); - const formatted = formatPseText(text); - - if (formatted === text) {{ - return []; - }} - - const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(text.length)); - return [vscode.TextEdit.replace(fullRange, formatted)]; -}} - -/** - * @param {{vscode.ExtensionContext}} context - */ -function activate(context) {{ - const textxExtension = vscode.extensions.getExtension('textX.textX'); - if (textxExtension && !textxExtension.isActive) {{ - textxExtension.activate(); - }} - - context.subscriptions.push( - vscode.languages.registerCompletionItemProvider( - 'pse', - {{ provideCompletionItems }}, - ' ', - '=', - '\\t' + return render_template( + "editor/extension.js.tmpl", + { + "Keywords": json.dumps(KEYWORD_COMPLETIONS, indent=2), + "PrimitiveTypes": json.dumps(PRIMITIVE_TYPE_COMPLETIONS, indent=2), + "Values": json.dumps(VALUE_COMPLETIONS, indent=2), + }, ) - ); - - context.subscriptions.push( - vscode.languages.registerDocumentFormattingEditProvider( - 'pse', - {{ provideDocumentFormattingEdits }} - ) - ); -}} - -function deactivate() {{}} - -module.exports = {{ - activate, - deactivate, -}}; -""" def normalize_vsix_package(output_path: str): @@ -265,12 +147,7 @@ def generate_vscode_extension(output_path: str, overwrite: bool = True): os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) command = build_command(output_path, overwrite=overwrite) - result = subprocess.run( - command, - text=True, - capture_output=True, - ) - + result = subprocess.run(command, text=True, capture_output=True) if result.returncode == 0: normalize_vsix_package(output_path) print(f"Generated VS Code extension: {output_path}") @@ -307,8 +184,6 @@ def build_parser(): def main(argv=None): - # Importing the descriptor keeps a direct reference to the registered language - # and makes this command fail early if language registration breaks. _ = pse_language args = build_parser().parse_args(argv) return generate_vscode_extension(args.output, overwrite=not args.no_overwrite) From b352a89f5796b94ded8396c1db9f960fb9838a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilija=20Spasi=C4=87?= Date: Sun, 12 Jul 2026 15:51:27 +0200 Subject: [PATCH 2/4] refactor: update tests --- tests/fixtures/clean_architecture.pse | 18 +++ tests/fixtures/webapi_no_infrastructure.pse | 10 ++ tests/test_deployment.py | 133 ++++++++++++++++++++ tests/test_dsl_service.py | 69 ++++++++++ tests/test_generation_e2e.py | 37 ++++++ tests/test_generation_ownership.py | 59 +++++++++ tests/test_vscode.py | 5 + 7 files changed, 331 insertions(+) create mode 100644 tests/fixtures/clean_architecture.pse create mode 100644 tests/fixtures/webapi_no_infrastructure.pse create mode 100644 tests/test_deployment.py create mode 100644 tests/test_generation_e2e.py create mode 100644 tests/test_generation_ownership.py diff --git a/tests/fixtures/clean_architecture.pse b/tests/fixtures/clean_architecture.pse new file mode 100644 index 0000000..7f542e8 --- /dev/null +++ b/tests/fixtures/clean_architecture.pse @@ -0,0 +1,18 @@ +Project IdentityApi target=dotnet { + Archetype CleanArchitecture + + Context Identity { + Entity User { + Guid Id + Email Email + } + + ValueObject Email { + String Value + } + + Aggregate UserAggregate { + root User + } + } +} diff --git a/tests/fixtures/webapi_no_infrastructure.pse b/tests/fixtures/webapi_no_infrastructure.pse new file mode 100644 index 0000000..fe07b0c --- /dev/null +++ b/tests/fixtures/webapi_no_infrastructure.pse @@ -0,0 +1,10 @@ +Project CatalogApi target=dotnet { + Archetype WebApi + + Context Catalog { + Entity Product { + String Code + String Name + } + } +} diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..fdd72c1 --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,133 @@ +import os +import tempfile +import unittest + +import yaml + +from pse.generators.dotnet.deployment import create_deployment +from pse.model.architecture import ( + ArchitectureModel, + Cache, + Database, + Deployment, + Infrastructure, + MessageBroker, + Project, +) +from pse.model.generation_context import GenerationContext +from pse.generators.dotnet.structure_sections import cleanup_inactive_cqrs_files + + +class DeploymentGeneratorTests(unittest.TestCase): + def context(self, output_dir, target): + architecture = ArchitectureModel( + project=Project(name="StoreApi", archetype="WebApi", target="dotnet"), + contexts=[], + infrastructure=Infrastructure( + database=Database("PostgreSQL"), + cache=Cache("Redis"), + broker=MessageBroker("RabbitMQ"), + ), + deployment=Deployment(target=target) if target else None, + ) + return GenerationContext( + architecture=architecture, + output_dir=output_dir, + versions={"dotnet": "10.0", "postgres": "17", "redis": "8", "rabbitmq": "4"}, + ) + + def test_missing_deployment_generates_nothing(self): + with tempfile.TemporaryDirectory() as output_dir: + create_deployment(self.context(output_dir, None)) + self.assertEqual(os.listdir(output_dir), []) + + def test_inactive_cqrs_files_are_removed_during_regeneration(self): + with tempfile.TemporaryDirectory() as output_dir: + cqrs_dir = os.path.join(output_dir, "Cqrs") + os.makedirs(cqrs_dir) + requests = os.path.join(cqrs_dir, "OrderRequests.cs") + messages = os.path.join(cqrs_dir, "OrderMessages.cs") + open(requests, "w", encoding="utf-8").close() + open(messages, "w", encoding="utf-8").close() + context = type("Context", (), {"entities": [type("Entity", (), {"name": "Order"})()]})() + + cleanup_inactive_cqrs_files(output_dir, [context], "wolverine") + + self.assertFalse(os.path.exists(requests)) + self.assertTrue(os.path.exists(messages)) + + def test_docker_compose_has_build_healthchecks_and_persistence(self): + with tempfile.TemporaryDirectory() as output_dir: + create_deployment(self.context(output_dir, "Docker")) + + with open(os.path.join(output_dir, "docker-compose.yml"), encoding="utf-8") as handle: + compose = yaml.safe_load(handle) + dockerfile = self.read(output_dir, "Dockerfile") + + self.assertIn("build", compose["services"]["storeapi"]) + self.assertEqual( + compose["services"]["storeapi"]["depends_on"]["postgres"]["condition"], + "service_healthy", + ) + self.assertIn("postgres-data", compose["volumes"]) + self.assertEqual(compose["services"]["rabbitmq"]["environment"]["RABBITMQ_DEFAULT_USER"], "app") + self.assertIn("AS build", dockerfile) + self.assertIn("USER $APP_UID", dockerfile) + + def test_swarm_uses_images_rollouts_overlay_network_and_external_secrets(self): + with tempfile.TemporaryDirectory() as output_dir: + create_deployment(self.context(output_dir, "DockerSwarm")) + + stack_path = os.path.join(output_dir, "deploy", "swarm", "stack.yml") + with open(stack_path, encoding="utf-8") as handle: + stack = yaml.safe_load(handle) + + app = stack["services"]["storeapi"] + self.assertNotIn("build", app) + self.assertEqual(app["deploy"]["update_config"]["order"], "start-first") + self.assertEqual(stack["networks"]["backend"]["driver"], "overlay") + self.assertTrue(stack["secrets"]["database-connection"]["external"]) + self.assertTrue(stack["secrets"]["rabbitmq-password"]["external"]) + readme = self.read(output_dir, "deploy/swarm/README.md") + self.assertIn("docker stack deploy", readme) + self.assertNotIn("{{AppName}}", readme) + + def test_kubernetes_has_secure_app_and_stateful_dependencies(self): + with tempfile.TemporaryDirectory() as output_dir: + create_deployment(self.context(output_dir, "Kubernetes")) + + manifest_path = os.path.join(output_dir, "deploy", "kubernetes", "manifest.yaml") + with open(manifest_path, encoding="utf-8") as handle: + documents = list(yaml.safe_load_all(handle)) + + by_kind_name = { + (document["kind"], document["metadata"]["name"]): document + for document in documents + } + app = by_kind_name[("Deployment", "storeapi")] + container = app["spec"]["template"]["spec"]["containers"][0] + + self.assertEqual(app["spec"]["replicas"], 2) + self.assertEqual(container["readinessProbe"]["httpGet"]["path"], "/health/ready") + self.assertTrue(container["securityContext"]["readOnlyRootFilesystem"]) + self.assertIn(("StatefulSet", "postgres"), by_kind_name) + self.assertIn("volumeClaimTemplates", by_kind_name[("StatefulSet", "postgres")]["spec"]) + postgres = by_kind_name[("StatefulSet", "postgres")]["spec"]["template"]["spec"]["containers"][0] + self.assertIn( + {"name": "PGDATA", "value": "/var/lib/postgresql/data/pgdata"}, + postgres["env"], + ) + self.assertNotIn(("Secret", "storeapi-secrets"), by_kind_name) + self.assertTrue(os.path.exists(os.path.join(output_dir, "deploy", "kubernetes", "secret.example.yaml"))) + readme = self.read(output_dir, "deploy/kubernetes/README.md") + self.assertIn("kubectl -n storeapi rollout status", readme) + self.assertNotIn("{{AppName}}", readme) + + @staticmethod + def read(output_dir, relative_path): + with open(os.path.join(output_dir, relative_path), encoding="utf-8") as handle: + return handle.read() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dsl_service.py b/tests/test_dsl_service.py index 34e4899..68048c3 100644 --- a/tests/test_dsl_service.py +++ b/tests/test_dsl_service.py @@ -82,6 +82,75 @@ def test_unknown_capability_implementation_returns_diagnostic(self): self.assertIn("Capability 'CQRS' implementation 'Unknown' is not recognized", diagnostics[0].message) self.assertEqual(diagnostics[0].range.start.line, 3) + def test_supported_deployment_targets_are_valid(self): + for target in ("Docker", "DockerSwarm", "Swarm", "Kubernetes", "K8s"): + source = f"Project StoreApi target=dotnet {{ Archetype WebApi Deployment {target} }}" + with self.subTest(target=target): + self.assertTrue(parse_document(source, f"{target}.pse").is_valid) + + def test_unknown_deployment_target_returns_diagnostic(self): + source = "Project StoreApi target=dotnet { Archetype WebApi Deployment Nomad }" + + diagnostics = validate_document(source, "nomad.pse") + + self.assertEqual(len(diagnostics), 1) + self.assertIn("Deployment target 'Nomad' is not supported", diagnostics[0].message) + + def test_unimplemented_archetype_returns_diagnostic(self): + source = "Project Demo target=dotnet { Archetype Microservices }" + + diagnostics = validate_document(source, "microservices.pse") + + self.assertEqual(len(diagnostics), 1) + self.assertIn("Archetype 'Microservices' is not recognized", diagnostics[0].message) + + def test_duplicate_domain_type_across_contexts_returns_diagnostic(self): + source = """Project Demo target=dotnet { + Archetype WebApi + Context First { Entity User { Guid Id } } + Context Second { Entity User { Guid Id } } +}""" + + diagnostics = validate_document(source, "duplicates.pse") + + self.assertTrue(any("globally unique" in diagnostic.message for diagnostic in diagnostics)) + + def test_duplicate_property_returns_diagnostic(self): + source = "Project Demo target=dotnet { Archetype WebApi Context C { Entity E { Guid Id String Id } } }" + + diagnostics = validate_document(source, "properties.pse") + + self.assertTrue(any("duplicate entity property" in diagnostic.message for diagnostic in diagnostics)) + + def test_empty_entity_returns_diagnostic(self): + source = "Project Demo target=dotnet { Archetype WebApi Context C { Entity Order {} } }" + + diagnostics = validate_document(source, "empty-entity.pse") + + self.assertEqual(len(diagnostics), 1) + self.assertIn("must define at least one property", diagnostics[0].message) + + def test_duplicate_capability_returns_diagnostic(self): + source = """Project Demo target=dotnet { + Archetype WebApi + Capabilities { + Capability logging = serilog + Capability logging = serilog + } +}""" + + diagnostics = validate_document(source, "duplicate-capability.pse") + + self.assertEqual(len(diagnostics), 1) + self.assertIn("declared more than once", diagnostics[0].message) + + def test_unsupported_infrastructure_returns_diagnostic(self): + source = "Project Demo target=dotnet { Archetype WebApi Infrastructure { Cache Memcached } }" + + diagnostics = validate_document(source, "infrastructure.pse") + + self.assertTrue(any("Memcached" in diagnostic.message for diagnostic in diagnostics)) + def test_property_types_accept_primitives_and_domain_types(self): source = """Project StoreApi target=dotnet { Archetype WebApi diff --git a/tests/test_generation_e2e.py b/tests/test_generation_e2e.py new file mode 100644 index 0000000..f07bddc --- /dev/null +++ b/tests/test_generation_e2e.py @@ -0,0 +1,37 @@ +import os +import pathlib +import shutil +import subprocess +import tempfile +import unittest + +from pse.bootstrap import run_pse + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUN_E2E = os.environ.get("PSE_RUN_E2E") == "1" + + +@unittest.skipUnless(RUN_E2E and shutil.which("dotnet"), "set PSE_RUN_E2E=1 to run generation builds") +class GenerationMatrixTests(unittest.TestCase): + CASES = ( + (ROOT / "pse" / "sample_with_capabilities.pse", "StoreApi"), + (ROOT / "tests" / "fixtures" / "webapi_no_infrastructure.pse", "CatalogApi"), + (ROOT / "tests" / "fixtures" / "clean_architecture.pse", "IdentityApi"), + ) + + def test_supported_generation_matrix_builds(self): + for source, project_name in self.CASES: + with self.subTest(source=source.name), tempfile.TemporaryDirectory() as output_dir: + self.assertTrue(run_pse(str(source), output_dir)) + result = subprocess.run( + ["dotnet", "build", os.path.join(output_dir, f"{project_name}.slnx")], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generation_ownership.py b/tests/test_generation_ownership.py new file mode 100644 index 0000000..d522bbd --- /dev/null +++ b/tests/test_generation_ownership.py @@ -0,0 +1,59 @@ +import json +import os +import tempfile +import unittest + +from pse.generation_ownership import MANIFEST_NAME, publish_generated_tree +from pse.template_loader import render_template + + +class GenerationOwnershipTests(unittest.TestCase): + def test_preserves_modified_files_and_removes_unchanged_stale_files(self): + with tempfile.TemporaryDirectory() as output_dir, tempfile.TemporaryDirectory() as first_stage: + self.write(first_stage, "keep.txt", "generated-v1") + self.write(first_stage, "stale.txt", "generated") + publish_generated_tree(first_stage, output_dir, overwrite=True) + + self.write(output_dir, "keep.txt", "user change") + + with tempfile.TemporaryDirectory() as second_stage: + self.write(second_stage, "keep.txt", "generated-v2") + self.write(second_stage, "new.txt", "new") + publish_generated_tree(second_stage, output_dir, overwrite=False) + + self.assertEqual(self.read(output_dir, "keep.txt"), "user change") + self.assertEqual(self.read(output_dir, "new.txt"), "new") + self.assertFalse(os.path.exists(os.path.join(output_dir, "stale.txt"))) + + manifest = json.loads(self.read(output_dir, MANIFEST_NAME)) + self.assertNotIn("keep.txt", manifest["files"]) + self.assertIn("new.txt", manifest["files"]) + + def test_overwrite_replaces_modified_owned_file(self): + with tempfile.TemporaryDirectory() as output_dir, tempfile.TemporaryDirectory() as stage: + self.write(stage, "owned.txt", "generated") + self.write(output_dir, "owned.txt", "user change") + + publish_generated_tree(stage, output_dir, overwrite=True) + + self.assertEqual(self.read(output_dir, "owned.txt"), "generated") + + def test_template_loader_rejects_missing_values(self): + with self.assertRaisesRegex(ValueError, "unresolved placeholders"): + render_template("dotnet/CSharpClass.cs.tmpl", {"UsingLines": ""}) + + @staticmethod + def write(root, relative_path, content): + path = os.path.join(root, relative_path) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + + @staticmethod + def read(root, relative_path): + with open(os.path.join(root, relative_path), encoding="utf-8") as handle: + return handle.read() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vscode.py b/tests/test_vscode.py index 8a2c4ef..a511fe2 100644 --- a/tests/test_vscode.py +++ b/tests/test_vscode.py @@ -58,9 +58,14 @@ def test_generated_vsix_contributes_pse_language_and_syntax(self): self.assertIn("registerCompletionItemProvider", extension_js) self.assertIn("PRIMITIVE_TYPES", extension_js) self.assertIn("Guid", extension_js) + self.assertIn("DockerSwarm", extension_js) + self.assertIn("Kubernetes", extension_js) self.assertIn("Project", extension_js) self.assertIn("registerDocumentFormattingEditProvider", extension_js) self.assertIn("formatPseText", extension_js) + self.assertNotIn("{{Keywords}}", extension_js) + self.assertNotIn("{{PrimitiveTypes}}", extension_js) + self.assertNotIn("{{Values}}", extension_js) if __name__ == "__main__": From febe913826547cb57e2869a797f6c425bd8f60ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilija=20Spasi=C4=87?= Date: Sun, 12 Jul 2026 15:52:14 +0200 Subject: [PATCH 3/4] chore: update docs --- HOWTO.md | 12 ++++---- README.md | 77 +++++++++++++++++++++++--------------------------- pyproject.toml | 2 ++ 3 files changed, 44 insertions(+), 47 deletions(-) diff --git a/HOWTO.md b/HOWTO.md index 294b77c..3e2a1ab 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -19,7 +19,7 @@ Project StoreApi target=dotnet { ### Supported constructs - `Project target=`: root declaration. -- `Archetype `: project layout (e.g., WebApi, CleanArchitecture, ModularMonolith, Microservices). +- `Archetype `: project layout (`WebApi` or `CleanArchitecture`). - `Capabilities { Capability }`: optional explicit capability selections. - `Context { ... }`: bounded context. - `Entity { }`: domain entity with properties. @@ -104,10 +104,9 @@ Key integration templates: - `Program.cs.tmpl`: entrypoint wiring for infra. - `AppSettings.json.tmpl` and `AppSettings.Development.json.tmpl`. - `DbContext.cs.tmpl` for database context. -- `OptionsClass.cs.tmpl` for options types. -- `Controller.cs.tmpl` and `ControllerMethods.cs.tmpl` for thin CRUD controllers that call repositories. -- `RepositoryInterface.cs.tmpl` and `RepositoryImplementation.cs.tmpl` for generic CRUD repository scaffolding. -- `TestClass.cs.tmpl` for xUnit placeholder tests. +- `Controller.cs.tmpl` plus service/CQRS method templates for thin CRUD controllers. +- `RepositoryInterface.cs.tmpl`, `RepositoryImplementation.cs.tmpl`, and `EfRepositoryImplementation.cs.tmpl` for in-memory or EF Core persistence. +- `TestClass.cs.tmpl` for generated xUnit entity tests. ## How capability selection works @@ -146,6 +145,8 @@ textx list-generators textx generate pse/sample_with_capabilities.pse --target dotnet -o ./sample_output --overwrite ``` +PSE tracks generated-file hashes in `.pse-generated.json`. The `pse` command overwrites owned files by default; pass `--no-overwrite` to preserve modified files while updating unchanged generated output. + ## textX-LS editor support PSE is registered as a textX language package, so editor support is provided through the upstream `textX-LS` project rather than a custom language server. `textX-LS` discovers PSE through the `textx_languages` entry point after this project is installed. @@ -186,4 +187,3 @@ Generate and install the VS Code extension for `.pse` files: pse-vscode-extension --output dist/pse-0.1.0.vsix code --install-extension dist/pse-0.1.0.vsix ``` - diff --git a/README.md b/README.md index 1472999..fcf58a4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Generate through the registered textX generator: textx generate pse/sample_with_capabilities.pse --target dotnet -o ./pse/sample_output --overwrite ``` +Generated-file hashes are stored in `.pse-generated.json`. The CLI overwrites owned files by default; pass `--no-overwrite` to preserve files you have modified while still refreshing unchanged generated output. + For generated .NET output, the `dotnet` CLI must be installed and available on `PATH`. For VS Code/textX-LS editor support, install the editor extras: @@ -145,15 +147,17 @@ generate_dotnet(ctx) ├── create_projects() ├── create_structure() ├── create_integration() - ├── restore_packages() - └── create_docker() + ├── configure_packages() + ├── create_deployment() + └── clean_solution() ``` -**Docker Generation (Basic Infrastructure Output)** +**Deployment Generation** -- Dockerfile generation and runtime image wiring. -- Tied to deployment target in the architecture model. -- Uses templates in [pse/templates/dotnet](pse/templates/dotnet). +- `Docker`: multi-stage, non-root image plus a local Compose stack with health checks and persistent dependency volumes. +- `DockerSwarm`: image-based stack, overlay networking, rolling update/rollback policy, resource limits, persistence, and external secrets. +- `Kubernetes`: namespace, stateless API Deployment and Service, probes, resources, restricted security context, and stateful infrastructure workloads with persistent volume claims. +- All targets are selected from the same architecture model. YAML is emitted from structured data; the .NET image uses [pse/templates/dotnet/Dockerfile.tmpl](pse/templates/dotnet/Dockerfile.tmpl). **Integration Scaffolding** @@ -162,16 +166,16 @@ generate_dotnet(ctx) - Adds `AppDbContext` in Infrastructure when a database is declared. - Wires EF Core, Redis cache, and MassTransit in Program.cs using templates. -**Controller and Repository Scaffolding** +**Controller and Application Scaffolding** -- Generates thin API controllers that inject the entity repository. +- Generates thin API controllers that use Application services, or CQRS requests when MediatR/Wolverine is selected. - Emits CRUD controller actions: `GetAll`, `GetById`, `Create`, `Update`, and `Delete`. -- Generates domain repository interfaces plus infrastructure repository stubs with matching CRUD methods. -- Keeps the controller-to-repository mapping generic with simple entity/DTO conversion helpers. +- Application services own the use-case boundary and depend on domain repository interfaces. +- Infrastructure repository implementations remain behind the Application layer. **Test Scaffolding** -- Generates xUnit placeholder tests per entity. +- Generates focused xUnit construction and property assertions per entity. - Uses one passing fact per test class so the test project starts in a runnable state. **Capability System + Resolver** @@ -207,8 +211,8 @@ PSE is an architecture compilation engine and a declarative, heuristic-driven sc - Capability swapping (MediatR → Wolverine, Serilog → built-in logging). - Multi-language targets via the same semantic model. -- Infrastructure scaling (Docker now, Kubernetes next). -- Archetype evolution (Clean Architecture, Modular Monolith, Microservices). +- Deployment target evolution through isolated Compose, Swarm, and Kubernetes generators. +- Archetype evolution through the validated Web API and Clean Architecture layouts. ### DSL @@ -262,7 +266,7 @@ Samples: ### Templates -All emitted content is rendered from templates in [pse/templates/dotnet](pse/templates/dotnet). Edit those files to customize Program.cs, controllers, repositories, Docker artifacts, test stubs, and class skeletons. +Generated text is kept under [pse/templates](pse/templates): .NET source templates live in `dotnet/`, the VS Code extension script in `editor/`, and deployment instructions in `deployment/`. Deployment YAML remains structured Python data so it can be composed and serialized safely. ### Editor Support With textX-LS @@ -289,9 +293,11 @@ code --install-extension dist/pse-0.1.0.vsix ### Generated Output Notes - `pse.manifest.json` is append-only and records `status`, `error`, and `finished_at` for each run. -- Controllers are intentionally thin and depend on repositories rather than application services. -- Repository implementations are generic stubs that are meant as a starting point, not final data access code. -- Test classes are placeholder xUnit facts, intended to be replaced with real assertions later. +- `.pse-generated.json` records hashes for files owned by the generator. Normal generation refreshes owned files and removes stale owned output. +- `pse --no-overwrite ...` preserves user-modified generated files while still refreshing unchanged owned files. +- Controllers are intentionally thin and call Application services, or dispatch CQRS messages when CQRS is enabled. +- Database projects receive EF Core repositories backed by `AppDbContext`; projects without a database receive thread-safe in-memory repositories. +- Test classes contain runnable construction and property assertions and can be extended with domain behavior tests. ### Running @@ -307,6 +313,8 @@ Or through the registered textX generator: textx generate pse/sample_with_capabilities.pse --target dotnet -o ./sample_output --overwrite ``` +Use `pse sample_with_capabilities.pse -o ./sample_output --no-overwrite` when generated files contain edits that must be preserved. + --- ## Core Principles @@ -400,23 +408,7 @@ Domain Infrastructure ``` -### ModularMonolith - -```text -Modules - ├── Identity - ├── Orders - └── Billing -``` - -### Microservices - -```text -Services -Gateway -Shared -Infrastructure -``` +Other archetypes should only be added when their generated solution is covered by the same build matrix. --- @@ -489,16 +481,19 @@ docker-compose.yml ### Kubernetes ```yaml -deployments/ -services/ -configmaps/ +deploy/kubernetes/namespace.yaml +deploy/kubernetes/manifest.yaml +deploy/kubernetes/secret.example.yaml ``` -### Future Targets +### Docker Swarm + +```yaml +deploy/swarm/stack.yml +deploy/swarm/README.md +``` -* Kubernetes -* Docker Swarm -* Nomad +`secret.example.yaml` is never applied by the generated Kubernetes instructions. Supply real secrets using the cluster's secret-management workflow. Swarm secrets are declared as external and must be created before stack deployment. --- diff --git a/pyproject.toml b/pyproject.toml index e9a66a8..9d32b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,4 +40,6 @@ pse = [ "grammar/*.tx", "heuristics/*.yaml", "templates/dotnet/*.tmpl", + "templates/deployment/*.tmpl", + "templates/editor/*.tmpl", ] From c01a53dfde069afc16694acd3ed0b5e79ba1e72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilija=20Spasi=C4=87?= Date: Sun, 12 Jul 2026 15:52:29 +0200 Subject: [PATCH 4/4] chore: update sample result --- pse/sample_output/.pse-generated.json | 41 ++++++++++++ pse/sample_output/Dockerfile | 22 +++++- pse/sample_output/StoreApi.API/Program.cs | 34 +++++++--- .../Properties/launchSettings.json | 4 +- .../StoreApi.API/StoreApi.API.http | 2 +- .../StoreApi.API/appsettings.Development.json | 6 -- .../StoreApi.API/appsettings.json | 6 -- .../Options/DatabaseOptions.cs | 6 -- .../Options/RedisOptions.cs | 6 -- .../Aggregates/OrderAggregate.cs | 15 +++++ .../Messaging/MessageBusOptions.cs | 6 -- .../Persistence/AppDbContext.cs | 10 +++ .../Persistence/PersistenceOptions.cs | 6 -- .../Repositories/OrderItemRepository.cs | 47 ++++++------- .../Repositories/OrderRepository.cs | 47 ++++++------- .../StoreApi.Infrastructure.csproj | 1 + .../StoreApi.Tests/StoreApi.Tests.csproj | 5 +- pse/sample_output/docker-compose.yml | 67 ++++++++++++++----- pse/sample_output/pse.manifest.json | 48 ++++++++++++- 19 files changed, 260 insertions(+), 119 deletions(-) create mode 100644 pse/sample_output/.pse-generated.json delete mode 100644 pse/sample_output/StoreApi.Application/Options/DatabaseOptions.cs delete mode 100644 pse/sample_output/StoreApi.Application/Options/RedisOptions.cs create mode 100644 pse/sample_output/StoreApi.Domain/Aggregates/OrderAggregate.cs delete mode 100644 pse/sample_output/StoreApi.Infrastructure/Messaging/MessageBusOptions.cs delete mode 100644 pse/sample_output/StoreApi.Infrastructure/Persistence/PersistenceOptions.cs diff --git a/pse/sample_output/.pse-generated.json b/pse/sample_output/.pse-generated.json new file mode 100644 index 0000000..6a56e70 --- /dev/null +++ b/pse/sample_output/.pse-generated.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "files": { + "Dockerfile": "b1c777e1f120a276d8cbaeb5e15f57fc21c479cdd5e0635d1b460e21cc5d469c", + "StoreApi.API/Controllers/OrderController.cs": "7081acb7158d12d9a4731e279309170202631fdd67dfa941ae4dd633d28112c8", + "StoreApi.API/Controllers/OrderItemController.cs": "edaea8e0cc6dd1f788cfb73c639bffbe48ea5a0b83c1c9bc9ab0f89f1384dae9", + "StoreApi.API/Dtos/OrderDto.cs": "9009b1df3e140a5023c59f9a06ec001c0c43b8ae0e05e696c61b274eb8c774ce", + "StoreApi.API/Dtos/OrderItemDto.cs": "2c8b78a8bb68bbb1e549f9dbfc6cf47c1f0c6a9fa990650b09920e1bd9af5917", + "StoreApi.API/Mapping/MappingConfig.cs": "225b228d792ff83b1bfdf9721ca411cf068642952b88e0e243d66f42b6cc20df", + "StoreApi.API/Program.cs": "ec20be5966715a339e6ec4e947922c7d2fb74b14f4e8996480000d3b3745a0a2", + "StoreApi.API/Properties/launchSettings.json": "7845b11430c3ff5bef61963ea7dbde8ed96ce849009c0d93ae2b1d6b135b147b", + "StoreApi.API/StoreApi.API.csproj": "1843f8a484818fa152c4652260691862226ba15ce8ec1003f0951bfdb1a50ebc", + "StoreApi.API/StoreApi.API.http": "db680a74a8bda978e59c484a6c9dfebab1721f485fd20fd3387d497f4aec7165", + "StoreApi.API/Validators/OrderDtoValidator.cs": "8319f56340c3bedf0c26b3181acce6292d6c656dc2b4fb267e7a1b7423674680", + "StoreApi.API/Validators/OrderItemDtoValidator.cs": "38aaa5de2b8d2612844892f9f84eaae5bbe3ab99bab70e76368158577eae2f55", + "StoreApi.API/appsettings.Development.json": "7386c435c7e118fca086b47a9f94f49e297cc591c940d9ba580f253b084d391f", + "StoreApi.API/appsettings.json": "691949496fde39d1a7156005b6ecab28e56db66b94a9dcdd1059ab5704c41385", + "StoreApi.Application/Cqrs/OrderItemRequests.cs": "a1f6879090746ca0531406ec355f3389bf471617ccf4a292d87e45ca97e41cb4", + "StoreApi.Application/Cqrs/OrderRequests.cs": "6c06bee43e74a2c3b665ccdf1d9f198e68baee3a50151c2f922c3afaa26796a6", + "StoreApi.Application/Interfaces/IOrderItemService.cs": "c4d34afdffeec6263f42c6fea8fc9a410baee62ae106a61adc94a6b47b524e99", + "StoreApi.Application/Interfaces/IOrderService.cs": "f28a0bad0c993d68cae6b6d1252f5cfb1c7e1aac09b08bac471f193342818f82", + "StoreApi.Application/Services/OrderItemService.cs": "90554a20be37fc31bf75bfbd3d4dd8a6c4412130e89aae949a2133cf728b000e", + "StoreApi.Application/Services/OrderService.cs": "3e62afc829072bbd68e9403fbe0ff61fdec5f2b2817994da7375bedcf166be08", + "StoreApi.Application/StoreApi.Application.csproj": "bf9b15e5284daae1ffc2adfb214265250ba920274b497ed836ff650a46f4fbdc", + "StoreApi.Domain/Aggregates/OrderAggregate.cs": "78000cbd1382444cc307fb824014bb6fd641d59c774d4f7f4465adcb803e13d2", + "StoreApi.Domain/Entities/Order.cs": "1660562c06e361b4f7a3b923f5c94f8d7e7c0bed30a10647b9ea79e37ab19c37", + "StoreApi.Domain/Entities/OrderItem.cs": "f4ea8a2d3d6f6d2e7a0fe2e65b1cf83be4279e5178dd595dba3ee00860e051a1", + "StoreApi.Domain/Repositories/IOrderItemRepository.cs": "33bd9d9f0b2a08c0c78e9b7f15738659c2ac92f0a5963dd2918577999765a999", + "StoreApi.Domain/Repositories/IOrderRepository.cs": "5463beb5b05db4d235ca72a122c60532908e2b175983c123d9a661b58b82e0d9", + "StoreApi.Domain/StoreApi.Domain.csproj": "11c0c110e19c9eaeaef66a0382f7a3e4ff6ab395d99ca6664e1ba378371cc47c", + "StoreApi.Infrastructure/Persistence/AppDbContext.cs": "4221470c5b4666baeb468da1620dbad4536ad4143797e58d7f5e64c89e4ffcc9", + "StoreApi.Infrastructure/Repositories/OrderItemRepository.cs": "e551e14d16fbd22230f4f83bd7fcc931c0c8a7560a6793133ca55ccf08038fcd", + "StoreApi.Infrastructure/Repositories/OrderRepository.cs": "5f89e01b436ca2c88205b8a684aa6b378578bac090b4706377cc694500870124", + "StoreApi.Infrastructure/StoreApi.Infrastructure.csproj": "971c4fdd52a77f679cb9abc20cd2dfd3fb93f814836fa3d4baf9253f0da71537", + "StoreApi.Tests/StoreApi.Tests.csproj": "4669bb5aea70e4bb4fabc3956621d323265c8c37497dc0f77fd87df8f0c713d4", + "StoreApi.Tests/Unit/OrderItemTests.cs": "d4dd4b5c1553256e1994c06202a7aa50f5ad210560aed47ca5b1b196da0f9a88", + "StoreApi.Tests/Unit/OrderTests.cs": "e8fdccf2b4840f0b8cad87fe963a581dbe39c65bf5e5403dd86d80530719d9ec", + "StoreApi.slnx": "53260bf4b6ee7726f839f709e072798d3ce39bdb14bddfd9d77c3b7566d8250e", + "docker-compose.yml": "a633d0bdb391619d35f14aa0c3cd9317115e1fdf83e699e593112b26c0de3555" + } +} diff --git a/pse/sample_output/Dockerfile b/pse/sample_output/Dockerfile index 9f2800f..65a92d7 100644 --- a/pse/sample_output/Dockerfile +++ b/pse/sample_output/Dockerfile @@ -1,6 +1,24 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY . . +RUN dotnet restore "StoreApi.API/StoreApi.API.csproj" +RUN dotnet publish "StoreApi.API/StoreApi.API.csproj" \ + --configuration $BUILD_CONFIGURATION \ + --output /app/publish \ + --no-restore \ + /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +USER root +RUN apt-get update \ + && apt-get install --yes --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 -COPY . . +COPY --from=build /app/publish . +USER $APP_UID +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl --fail --silent http://localhost:8080/health/live || exit 1 ENTRYPOINT ["dotnet", "StoreApi.API.dll"] diff --git a/pse/sample_output/StoreApi.API/Program.cs b/pse/sample_output/StoreApi.API/Program.cs index 0eb13c8..22999d7 100644 --- a/pse/sample_output/StoreApi.API/Program.cs +++ b/pse/sample_output/StoreApi.API/Program.cs @@ -1,20 +1,23 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Serilog; using FluentValidation; using FluentValidation.AspNetCore; using Mapster; using MapsterMapper; using StoreApi.API.Mapping; +using StoreApi.Application.Cqrs; using StoreApi.Application.Interfaces; using StoreApi.Application.Services; -using StoreApi.Application.Cqrs; using StoreApi.Domain.Repositories; using StoreApi.Infrastructure.Repositories; -using StoreApi.Application.Options; using Microsoft.EntityFrameworkCore; using StoreApi.Infrastructure.Persistence; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddKeyPerFile("/run/secrets", optional: true); + builder.Host.UseSerilog((context, services, loggerConfiguration) => loggerConfiguration .ReadFrom.Configuration(context.Configuration) @@ -23,6 +26,10 @@ builder.Services.AddControllers(); +builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" }) + .AddDbContextCheck("database", tags: new[] { "ready" }); + builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -30,24 +37,35 @@ MappingConfig.Register(); builder.Services.AddMapster(); +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); + builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); -builder.Services.Configure(builder.Configuration.GetSection("Database")); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Database"))); -builder.Services.Configure(builder.Configuration.GetSection("Redis")); builder.Services.AddStackExchangeRedisCache(options => options.Configuration = builder.Configuration.GetConnectionString("Redis")); var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); +} app.MapControllers(); +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("live") +}); +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = _ => true +}); app.Run(); diff --git a/pse/sample_output/StoreApi.API/Properties/launchSettings.json b/pse/sample_output/StoreApi.API/Properties/launchSettings.json index 7455cb2..2e2e329 100644 --- a/pse/sample_output/StoreApi.API/Properties/launchSettings.json +++ b/pse/sample_output/StoreApi.API/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5214", + "applicationUrl": "http://localhost:5128", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7228;http://localhost:5214", + "applicationUrl": "https://localhost:7006;http://localhost:5128", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/pse/sample_output/StoreApi.API/StoreApi.API.http b/pse/sample_output/StoreApi.API/StoreApi.API.http index 48c1588..2317398 100644 --- a/pse/sample_output/StoreApi.API/StoreApi.API.http +++ b/pse/sample_output/StoreApi.API/StoreApi.API.http @@ -1,4 +1,4 @@ -@StoreApi.API_HostAddress = http://localhost:5214 +@StoreApi.API_HostAddress = http://localhost:5128 GET {{StoreApi.API_HostAddress}}/weatherforecast/ Accept: application/json diff --git a/pse/sample_output/StoreApi.API/appsettings.Development.json b/pse/sample_output/StoreApi.API/appsettings.Development.json index 943c96b..22a0707 100644 --- a/pse/sample_output/StoreApi.API/appsettings.Development.json +++ b/pse/sample_output/StoreApi.API/appsettings.Development.json @@ -3,12 +3,6 @@ "Database": "Host=localhost;Port=5432;Database=app;Username=postgres;Password=postgres", "Redis": "localhost:6379" }, - "Database": { - "Provider": "Postgres" - }, - "Redis": { - "Enabled": true - }, "Logging": { "LogLevel": { diff --git a/pse/sample_output/StoreApi.API/appsettings.json b/pse/sample_output/StoreApi.API/appsettings.json index 9d99d7a..62184f6 100644 --- a/pse/sample_output/StoreApi.API/appsettings.json +++ b/pse/sample_output/StoreApi.API/appsettings.json @@ -3,12 +3,6 @@ "Database": "Host=localhost;Port=5432;Database=app;Username=postgres;Password=postgres", "Redis": "localhost:6379" }, - "Database": { - "Provider": "Postgres" - }, - "Redis": { - "Enabled": true - }, "Logging": { "LogLevel": { diff --git a/pse/sample_output/StoreApi.Application/Options/DatabaseOptions.cs b/pse/sample_output/StoreApi.Application/Options/DatabaseOptions.cs deleted file mode 100644 index cafc869..0000000 --- a/pse/sample_output/StoreApi.Application/Options/DatabaseOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StoreApi.Application.Options; - -public class DatabaseOptions -{ - public string ConnectionString { get; set; } = string.Empty; -} diff --git a/pse/sample_output/StoreApi.Application/Options/RedisOptions.cs b/pse/sample_output/StoreApi.Application/Options/RedisOptions.cs deleted file mode 100644 index 3e01715..0000000 --- a/pse/sample_output/StoreApi.Application/Options/RedisOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StoreApi.Application.Options; - -public class RedisOptions -{ - public string ConnectionString { get; set; } = string.Empty; -} diff --git a/pse/sample_output/StoreApi.Domain/Aggregates/OrderAggregate.cs b/pse/sample_output/StoreApi.Domain/Aggregates/OrderAggregate.cs new file mode 100644 index 0000000..455a290 --- /dev/null +++ b/pse/sample_output/StoreApi.Domain/Aggregates/OrderAggregate.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using StoreApi.Domain.Entities; + +namespace StoreApi.Domain.Aggregates; + +public class OrderAggregate +{ + public Order Root { get; } + public List OrderItems { get; } = new(); + + public OrderAggregate(Order root) + { + Root = root; + } +} diff --git a/pse/sample_output/StoreApi.Infrastructure/Messaging/MessageBusOptions.cs b/pse/sample_output/StoreApi.Infrastructure/Messaging/MessageBusOptions.cs deleted file mode 100644 index e7cefaa..0000000 --- a/pse/sample_output/StoreApi.Infrastructure/Messaging/MessageBusOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StoreApi.Infrastructure.Messaging; - -public class MessageBusOptions -{ - // Infrastructure messaging options. -} diff --git a/pse/sample_output/StoreApi.Infrastructure/Persistence/AppDbContext.cs b/pse/sample_output/StoreApi.Infrastructure/Persistence/AppDbContext.cs index 49d1ac6..8a4bf98 100644 --- a/pse/sample_output/StoreApi.Infrastructure/Persistence/AppDbContext.cs +++ b/pse/sample_output/StoreApi.Infrastructure/Persistence/AppDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using StoreApi.Domain.Entities; namespace StoreApi.Infrastructure.Persistence; @@ -8,4 +9,13 @@ public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet OrderSet => Set(); + public DbSet OrderItemSet => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(entity => entity.Id); + modelBuilder.Entity().HasKey(entity => entity.ProductId); + } } diff --git a/pse/sample_output/StoreApi.Infrastructure/Persistence/PersistenceOptions.cs b/pse/sample_output/StoreApi.Infrastructure/Persistence/PersistenceOptions.cs deleted file mode 100644 index f4af40a..0000000 --- a/pse/sample_output/StoreApi.Infrastructure/Persistence/PersistenceOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StoreApi.Infrastructure.Persistence; - -public class PersistenceOptions -{ - // Infrastructure persistence options. -} diff --git a/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderItemRepository.cs b/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderItemRepository.cs index 0393a0b..da84dd0 100644 --- a/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderItemRepository.cs +++ b/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderItemRepository.cs @@ -1,57 +1,54 @@ -using System; using System.Collections.Generic; using System.Linq; +using Microsoft.EntityFrameworkCore; using StoreApi.Domain.Entities; using StoreApi.Domain.Repositories; +using StoreApi.Infrastructure.Persistence; namespace StoreApi.Infrastructure.Repositories; public class OrderItemRepository : IOrderItemRepository { - private readonly List _items = new(); + private readonly AppDbContext _dbContext; + private readonly DbSet _entities; + + public OrderItemRepository(AppDbContext dbContext) + { + _dbContext = dbContext; + _entities = dbContext.Set(); + } public IEnumerable GetAll() { - return _items; + return _entities.AsNoTracking().ToList(); } public OrderItem? GetById(Guid id) { - return _items.FirstOrDefault(entity => - EqualityComparer.Default.Equals(entity.ProductId, id)); + return _entities.Find(id); } public void Create(OrderItem entity) { - if (GetById(entity.ProductId) is not null) - { - Update(entity); - return; - } - - _items.Add(entity); + _entities.Add(entity); + _dbContext.SaveChanges(); } public void Update(OrderItem entity) { - var index = _items.FindIndex(existing => - EqualityComparer.Default.Equals(existing.ProductId, entity.ProductId)); - - if (index >= 0) - { - _items[index] = entity; - return; - } - - _items.Add(entity); + _entities.Update(entity); + _dbContext.SaveChanges(); } public void Delete(Guid id) { - var entity = GetById(id); - if (entity is not null) + var entity = _entities.Find(id); + if (entity is null) { - _items.Remove(entity); + return; } + + _entities.Remove(entity); + _dbContext.SaveChanges(); } } diff --git a/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderRepository.cs b/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderRepository.cs index c07e54f..14a141c 100644 --- a/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderRepository.cs +++ b/pse/sample_output/StoreApi.Infrastructure/Repositories/OrderRepository.cs @@ -1,57 +1,54 @@ -using System; using System.Collections.Generic; using System.Linq; +using Microsoft.EntityFrameworkCore; using StoreApi.Domain.Entities; using StoreApi.Domain.Repositories; +using StoreApi.Infrastructure.Persistence; namespace StoreApi.Infrastructure.Repositories; public class OrderRepository : IOrderRepository { - private readonly List _items = new(); + private readonly AppDbContext _dbContext; + private readonly DbSet _entities; + + public OrderRepository(AppDbContext dbContext) + { + _dbContext = dbContext; + _entities = dbContext.Set(); + } public IEnumerable GetAll() { - return _items; + return _entities.AsNoTracking().ToList(); } public Order? GetById(Guid id) { - return _items.FirstOrDefault(entity => - EqualityComparer.Default.Equals(entity.Id, id)); + return _entities.Find(id); } public void Create(Order entity) { - if (GetById(entity.Id) is not null) - { - Update(entity); - return; - } - - _items.Add(entity); + _entities.Add(entity); + _dbContext.SaveChanges(); } public void Update(Order entity) { - var index = _items.FindIndex(existing => - EqualityComparer.Default.Equals(existing.Id, entity.Id)); - - if (index >= 0) - { - _items[index] = entity; - return; - } - - _items.Add(entity); + _entities.Update(entity); + _dbContext.SaveChanges(); } public void Delete(Guid id) { - var entity = GetById(id); - if (entity is not null) + var entity = _entities.Find(id); + if (entity is null) { - _items.Remove(entity); + return; } + + _entities.Remove(entity); + _dbContext.SaveChanges(); } } diff --git a/pse/sample_output/StoreApi.Infrastructure/StoreApi.Infrastructure.csproj b/pse/sample_output/StoreApi.Infrastructure/StoreApi.Infrastructure.csproj index 6817cb5..cdc9897 100644 --- a/pse/sample_output/StoreApi.Infrastructure/StoreApi.Infrastructure.csproj +++ b/pse/sample_output/StoreApi.Infrastructure/StoreApi.Infrastructure.csproj @@ -8,6 +8,7 @@ + diff --git a/pse/sample_output/StoreApi.Tests/StoreApi.Tests.csproj b/pse/sample_output/StoreApi.Tests/StoreApi.Tests.csproj index a4c91a8..b9fa8b0 100644 --- a/pse/sample_output/StoreApi.Tests/StoreApi.Tests.csproj +++ b/pse/sample_output/StoreApi.Tests/StoreApi.Tests.csproj @@ -8,10 +8,7 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + diff --git a/pse/sample_output/docker-compose.yml b/pse/sample_output/docker-compose.yml index 59dcb03..fce1a3a 100644 --- a/pse/sample_output/docker-compose.yml +++ b/pse/sample_output/docker-compose.yml @@ -1,25 +1,62 @@ -version: '3.9' services: storeapi: - build: . - container_name: storeapi + build: + context: . + dockerfile: Dockerfile + image: storeapi:latest ports: - - "8080:8080" + - 8080:8080 + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://+:8080 + ConnectionStrings__Database: Host=postgres;Port=5432;Database=app;Username=postgres;Password=postgres + ConnectionStrings__Redis: redis:6379 + networks: + - backend + restart: unless-stopped depends_on: - - db - - redis - # dependencies - db: + postgres: + condition: service_healthy + redis: + condition: service_healthy + postgres: image: postgres:17 - container_name: postgres environment: + POSTGRES_DB: app POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: app - ports: - - "5432:5432" + healthcheck: + test: + - CMD-SHELL + - pg_isready -U postgres -d app + interval: 10s + timeout: 5s + retries: 5 + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - backend redis: image: redis:8 - container_name: redis - ports: - - "6379:6379" + command: + - redis-server + - --appendonly + - 'yes' + healthcheck: + test: + - CMD + - redis-cli + - ping + interval: 10s + timeout: 5s + retries: 5 + volumes: + - redis-data:/data + networks: + - backend +networks: + backend: + driver: bridge +volumes: + postgres-data: {} + redis-data: {} diff --git a/pse/sample_output/pse.manifest.json b/pse/sample_output/pse.manifest.json index bf4d6e0..0bfe9e9 100644 --- a/pse/sample_output/pse.manifest.json +++ b/pse/sample_output/pse.manifest.json @@ -45,6 +45,52 @@ "status": "completed", "error": null, "finished_at": "2026-07-09T23:35:55Z" + }, + { + "id": "8540f980-340d-4fa4-bb7e-d617f4099560", + "timestamp": "2026-07-11T15:08:55Z", + "input_file": "/home/x/Projects/ProjectScaffoldingEngine/pse/sample_with_capabilities.pse", + "capabilities": [ + { + "name": "cache", + "value": "redis", + "source": "inferred" + }, + { + "name": "cqrs", + "value": "mediatr", + "source": "explicit" + }, + { + "name": "database", + "value": "postgres", + "source": "inferred" + }, + { + "name": "logging", + "value": "serilog", + "source": "explicit" + }, + { + "name": "mapping", + "value": "mapster", + "source": "explicit" + }, + { + "name": "testing", + "value": "xunit", + "source": "explicit" + }, + { + "name": "validation", + "value": "fluentvalidation", + "source": "explicit" + } + ], + "dsl": "Project StoreApi target=dotnet {\n\n Archetype WebApi\n\n Capabilities {\n Capability Logging\n Capability Validation\n Capability Mapping\n Capability CQRS = MediatR\n Capability Testing\n }\n\n Context Orders {\n\n Entity Order {\n Guid Id\n DateTime CreatedAt\n String Status\n }\n\n Entity OrderItem {\n Guid ProductId\n Int Quantity\n }\n\n Aggregate OrderAggregate {\n root Order\n children OrderItem\n }\n }\n\n Infrastructure {\n Database PostgreSQL\n Cache Redis\n }\n\n Deployment Docker\n}\n", + "status": "completed", + "error": null, + "finished_at": "2026-07-11T15:09:21Z" } ] -} \ No newline at end of file +}