ndsdk-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ndsdk/__init__.py +3 -0
- ndsdk/config_normalizer.py +306 -0
- ndsdk/generators/__init__.py +8 -0
- ndsdk/generators/base.py +93 -0
- ndsdk/generators/engine.py +715 -0
- ndsdk/main.py +536 -0
- ndsdk/naming.py +112 -0
- ndsdk/packages/client_provider/Program.fragment.cs.j2 +1 -0
- ndsdk/packages/client_provider/appsettings.fragment.json.j2 +14 -0
- ndsdk/packages/client_provider/package.yaml +13 -0
- ndsdk/packages/db_provider/Program.fragment.cs.j2 +3 -0
- ndsdk/packages/db_provider/appsettings.fragment.json.j2 +30 -0
- ndsdk/packages/db_provider/package.yaml +123 -0
- ndsdk/packages/exception_handling/Program.app.fragment.cs.j2 +1 -0
- ndsdk/packages/exception_handling/Program.fragment.cs.j2 +1 -0
- ndsdk/packages/exception_handling/package.yaml +7 -0
- ndsdk/packages/file_storage_azure/Program.fragment.cs.j2 +1 -0
- ndsdk/packages/file_storage_azure/appsettings.fragment.json.j2 +6 -0
- ndsdk/packages/file_storage_azure/package.yaml +9 -0
- ndsdk/packages/observability/Program.fragment.cs.j2 +2 -0
- ndsdk/packages/observability/appsettings.fragment.json.j2 +39 -0
- ndsdk/packages/observability/package.yaml +16 -0
- ndsdk/registry.py +192 -0
- ndsdk/templates/_common/BO.cs.j2 +10 -0
- ndsdk/templates/_common/Controller.cs.j2 +131 -0
- ndsdk/templates/_common/DbProviderBase.cs.j2 +26 -0
- ndsdk/templates/_common/Dockerfile.j2 +15 -0
- ndsdk/templates/_common/Dto.cs.j2 +16 -0
- ndsdk/templates/_common/Entity.cs.j2 +16 -0
- ndsdk/templates/_common/IService.cs.j2 +25 -0
- ndsdk/templates/_common/Model.cs.j2 +16 -0
- ndsdk/templates/_common/Provider.cs.j2 +151 -0
- ndsdk/templates/_common/ProviderBase.cs.j2 +10 -0
- ndsdk/templates/_common/Service.cs.j2 +49 -0
- ndsdk/templates/_common/ServiceManager.cs.j2 +28 -0
- ndsdk/templates/_common/Solution.sln.j2 +22 -0
- ndsdk/templates/_common/_csproj_refs.inc.j2 +14 -0
- ndsdk/templates/_common/launchSettings.json.j2 +17 -0
- ndsdk/templates/microservice/clean/Api.csproj.j2 +12 -0
- ndsdk/templates/microservice/clean/ClassLib.csproj.j2 +11 -0
- ndsdk/templates/microservice/clean/Program.cs.j2 +35 -0
- ndsdk/templates/microservice/clean/layout.yaml +64 -0
- ndsdk/templates/microservice/layered/Program.cs.j2 +61 -0
- ndsdk/templates/microservice/layered/Project.csproj.j2 +12 -0
- ndsdk/templates/microservice/layered/layout.yaml +41 -0
- ndsdk/validators.py +213 -0
- ndsdk_cli-1.0.0.dist-info/METADATA +11 -0
- ndsdk_cli-1.0.0.dist-info/RECORD +51 -0
- ndsdk_cli-1.0.0.dist-info/WHEEL +5 -0
- ndsdk_cli-1.0.0.dist-info/entry_points.txt +2 -0
- ndsdk_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""Manifest-driven generation engine.
|
|
2
|
+
|
|
3
|
+
A single generator reads a ``layout.yaml`` describing a (type, style) variant and
|
|
4
|
+
emits files. New architectures are added by dropping a template folder with a
|
|
5
|
+
manifest under ``templates/<type>/<style>/`` — no Python changes — unless a
|
|
6
|
+
variant needs a brand-new composition step, which is added once as a *hook*.
|
|
7
|
+
|
|
8
|
+
Per-service config can carry:
|
|
9
|
+
|
|
10
|
+
* ``models`` / ``dtos`` / ``bos`` / ``providers`` / ``endpoints``
|
|
11
|
+
* ``integrations`` — external resources such as database, storage, queue, API
|
|
12
|
+
* ``packages`` — capabilities that must be injected into generated projects
|
|
13
|
+
* ``providers[].methods`` — common provider method descriptors. Database methods
|
|
14
|
+
use the same common shape as storage/queue/API methods; database-specific
|
|
15
|
+
details live under method ``settings``.
|
|
16
|
+
|
|
17
|
+
Selective regeneration: ``generate`` accepts a :class:`GenerationFilter` so a
|
|
18
|
+
single service and/or a single layer (model, provider, service, ...) can be
|
|
19
|
+
re-emitted without touching the rest of the solution.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import uuid
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from .. import naming
|
|
33
|
+
from ..config_normalizer import normalize_config
|
|
34
|
+
from ..registry import PackageRegistry, flatten_appsettings, merge_appsettings,_deep_merge
|
|
35
|
+
from . import base
|
|
36
|
+
|
|
37
|
+
TEMPLATES_DIR = base.TEMPLATES_DIR
|
|
38
|
+
|
|
39
|
+
# Stable namespace for deterministic project GUIDs (keeps the .sln stable across
|
|
40
|
+
# runs and across selective regeneration).
|
|
41
|
+
_GUID_NS = uuid.UUID("6f9619ff-8b86-d011-b42d-00cf4fc964ff")
|
|
42
|
+
|
|
43
|
+
HEALTH_ENDPOINT = {
|
|
44
|
+
"name": "health",
|
|
45
|
+
"http_method": "GET",
|
|
46
|
+
"route": "api/v1/health",
|
|
47
|
+
"produces": "text/plain",
|
|
48
|
+
"summary": "Health check endpoint for service liveness verification.",
|
|
49
|
+
"description": "Returns a simple status response indicating the service is reachable.",
|
|
50
|
+
"is_health": True,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_SCOPE_KEYS = {
|
|
54
|
+
"per_model": ("models", "model"),
|
|
55
|
+
"per_dto": ("dtos", "dto"),
|
|
56
|
+
"per_provider": ("providers", "provider"),
|
|
57
|
+
"per_bo": ("bos", "bo"),
|
|
58
|
+
"per_endpoint": ("business_endpoints", "endpoint"),
|
|
59
|
+
"per_entity": ("entities", "entity"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Default layer label derived from an artifact's scope when it declares none.
|
|
63
|
+
_SCOPE_DEFAULT_LAYER = {
|
|
64
|
+
"per_model": "model",
|
|
65
|
+
"per_dto": "dto",
|
|
66
|
+
"per_provider": "provider",
|
|
67
|
+
"per_bo": "bo",
|
|
68
|
+
"per_endpoint": "endpoint",
|
|
69
|
+
"per_entity": "entity",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ----------------------------- selective regen ---------------------------- #
|
|
74
|
+
@dataclass
|
|
75
|
+
class GenerationFilter:
|
|
76
|
+
"""Restrict a generation run to certain services and/or layers.
|
|
77
|
+
|
|
78
|
+
Empty sets mean "no restriction". When any restriction is active the engine
|
|
79
|
+
leaves the solution file and the config copy untouched, so regenerating a
|
|
80
|
+
single layer never rewrites unrelated solution structure.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
services: set[str] = field(default_factory=set)
|
|
84
|
+
layers: set[str] = field(default_factory=set)
|
|
85
|
+
force: bool = False
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def active(self) -> bool:
|
|
89
|
+
return bool(self.services or self.layers)
|
|
90
|
+
|
|
91
|
+
def allow_service(self, name: str) -> bool:
|
|
92
|
+
return not self.services or name in self.services
|
|
93
|
+
|
|
94
|
+
def allow_layer(self, layer: str) -> bool:
|
|
95
|
+
return not self.layers or layer in self.layers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ----------------------------- discovery ---------------------------------- #
|
|
99
|
+
def available_variants() -> list[tuple[str, str]]:
|
|
100
|
+
"""Return all (type, style) pairs that have a layout.yaml."""
|
|
101
|
+
out = []
|
|
102
|
+
for type_dir in sorted(TEMPLATES_DIR.iterdir()):
|
|
103
|
+
if not type_dir.is_dir() or type_dir.name.startswith("_"):
|
|
104
|
+
continue
|
|
105
|
+
for style_dir in sorted(type_dir.iterdir()):
|
|
106
|
+
if (style_dir / "layout.yaml").exists():
|
|
107
|
+
out.append((type_dir.name, style_dir.name))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_variant(config: dict) -> tuple[str, str, Path]:
|
|
112
|
+
project = config.get("project", {})
|
|
113
|
+
ptype = project.get("type", "microservice")
|
|
114
|
+
style = project.get("architecture", "layered")
|
|
115
|
+
variant_dir = TEMPLATES_DIR / ptype / style
|
|
116
|
+
if not (variant_dir / "layout.yaml").exists():
|
|
117
|
+
opts = ", ".join(f"{t}/{s}" for t, s in available_variants()) or "(none)"
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Unknown architecture '{ptype}/{style}'. Available: {opts}"
|
|
120
|
+
)
|
|
121
|
+
return ptype, style, variant_dir
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_generator(config: dict, output_dir: str) -> "Engine":
|
|
125
|
+
return Engine(config, output_dir)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def available_layers() -> list[str]:
|
|
129
|
+
"""Distinct layer labels declared across all shipped variants (for --layer)."""
|
|
130
|
+
layers: set[str] = set()
|
|
131
|
+
for ptype, style in available_variants():
|
|
132
|
+
layout = yaml.safe_load((TEMPLATES_DIR / ptype / style / "layout.yaml").read_text())
|
|
133
|
+
for proj in layout.get("projects", []):
|
|
134
|
+
for art in proj.get("artifacts", []):
|
|
135
|
+
layers.add(_artifact_layer(art))
|
|
136
|
+
return sorted(layers)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _artifact_layer(art: dict) -> str:
|
|
140
|
+
if art.get("layer"):
|
|
141
|
+
return art["layer"]
|
|
142
|
+
if "hook" in art:
|
|
143
|
+
return art["hook"]
|
|
144
|
+
return _SCOPE_DEFAULT_LAYER.get(art.get("scope", "once"), "once")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ------------------------------- engine ----------------------------------- #
|
|
148
|
+
class Engine:
|
|
149
|
+
def __init__(self, config: dict, output_dir: str):
|
|
150
|
+
self.config = normalize_config(config)
|
|
151
|
+
self.project = self.config["project"]
|
|
152
|
+
self.ptype, self.style, self.variant_dir = resolve_variant(self.config)
|
|
153
|
+
self.layout = yaml.safe_load((self.variant_dir / "layout.yaml").read_text())
|
|
154
|
+
|
|
155
|
+
self.solution_name = self.project["name"]
|
|
156
|
+
self.namespace_root = self.project.get("namespace_root", "")
|
|
157
|
+
self.dotnet = self.project.get("dotnet_version", "net8.0")
|
|
158
|
+
self.version_strategy = self.project.get("nuget_version_strategy", "floating")
|
|
159
|
+
self.config_versions = self.config.get("package_versions", {})
|
|
160
|
+
|
|
161
|
+
self.output_dir = Path(output_dir)
|
|
162
|
+
self.solution_dir = self.output_dir / self.solution_name
|
|
163
|
+
self.env = base.make_env(self.variant_dir)
|
|
164
|
+
self.packages = PackageRegistry()
|
|
165
|
+
self.errors: list[str] = []
|
|
166
|
+
|
|
167
|
+
def _package_versions_for_service(self, svc: dict) -> dict:
|
|
168
|
+
root_versions = self.config.get("package_versions", {}) or {}
|
|
169
|
+
service_versions = svc.get("package_versions", {}) or {}
|
|
170
|
+
|
|
171
|
+
# Service-level version wins over root-level version.
|
|
172
|
+
return {**root_versions, **service_versions}
|
|
173
|
+
|
|
174
|
+
# ---- package + namespace resolution per service --------------------- #
|
|
175
|
+
def _service_integrations(self, svc: dict) -> dict[str, dict]:
|
|
176
|
+
return {
|
|
177
|
+
i.get("name"): i
|
|
178
|
+
for i in svc.get("integrations", []) or []
|
|
179
|
+
if isinstance(i, dict) and i.get("name")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def _service_database(self, svc: dict) -> dict | None:
|
|
183
|
+
"""Resolve database settings from the common integration/provider model.
|
|
184
|
+
|
|
185
|
+
Public config no longer needs a separate `database:` block. A database is
|
|
186
|
+
just an integration whose kind is `database`, optionally referenced by a
|
|
187
|
+
provider whose kind is also `database`.
|
|
188
|
+
"""
|
|
189
|
+
integrations = self._service_integrations(svc)
|
|
190
|
+
selected: dict | None = None
|
|
191
|
+
|
|
192
|
+
for prov in svc.get("providers", []) or []:
|
|
193
|
+
integration = integrations.get(prov.get("integration"))
|
|
194
|
+
kind = str(prov.get("kind") or (integration or {}).get("kind") or "custom").lower()
|
|
195
|
+
if kind == "database":
|
|
196
|
+
selected = integration
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if selected is None:
|
|
200
|
+
selected = next(
|
|
201
|
+
(i for i in integrations.values() if str(i.get("kind", "")).lower() == "database"),
|
|
202
|
+
None,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Compatibility only: if normalize_config was bypassed and an old block
|
|
206
|
+
# is still present, use it internally without exposing it as the target
|
|
207
|
+
# public config shape.
|
|
208
|
+
legacy_db = svc.get("database") if isinstance(svc.get("database"), dict) else None
|
|
209
|
+
if selected is None and legacy_db is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
if selected is not None:
|
|
213
|
+
settings = selected.get("settings") if isinstance(selected.get("settings"), dict) else {}
|
|
214
|
+
provider = selected.get("provider") or selected.get("store")
|
|
215
|
+
db_type = settings.get("database_type") or selected.get("database_type") or selected.get("type")
|
|
216
|
+
out = {
|
|
217
|
+
"store": provider,
|
|
218
|
+
"provider": provider,
|
|
219
|
+
"type": db_type or "non_relational",
|
|
220
|
+
"connection_string_name": settings.get("connection_string_name"),
|
|
221
|
+
"integration": selected.get("name"),
|
|
222
|
+
}
|
|
223
|
+
else:
|
|
224
|
+
out = {
|
|
225
|
+
"store": legacy_db.get("store") or legacy_db.get("provider"),
|
|
226
|
+
"provider": legacy_db.get("provider") or legacy_db.get("store"),
|
|
227
|
+
"type": legacy_db.get("type", "non_relational"),
|
|
228
|
+
"connection_string_name": legacy_db.get("connection_string_name"),
|
|
229
|
+
"integration": legacy_db.get("integration"),
|
|
230
|
+
}
|
|
231
|
+
return {k: v for k, v in out.items() if v is not None}
|
|
232
|
+
|
|
233
|
+
def _service_storage(self, svc: dict) -> dict | None:
|
|
234
|
+
"""Resolve an Azure blob storage integration, if any.
|
|
235
|
+
|
|
236
|
+
Mirrors :meth:`_service_database`: a storage capability is just an
|
|
237
|
+
integration whose kind is ``storage`` (optionally referenced by a
|
|
238
|
+
provider whose kind is ``storage``). Returns the connection-string name
|
|
239
|
+
and container so the file-storage package can be auto-wired.
|
|
240
|
+
"""
|
|
241
|
+
integrations = self._service_integrations(svc)
|
|
242
|
+
selected: dict | None = None
|
|
243
|
+
|
|
244
|
+
for prov in svc.get("providers", []) or []:
|
|
245
|
+
integration = integrations.get(prov.get("integration"))
|
|
246
|
+
kind = str(prov.get("kind") or (integration or {}).get("kind") or "custom").lower()
|
|
247
|
+
if kind == "storage":
|
|
248
|
+
selected = integration
|
|
249
|
+
break
|
|
250
|
+
if selected is None:
|
|
251
|
+
selected = next(
|
|
252
|
+
(i for i in integrations.values() if str(i.get("kind", "")).lower() == "storage"),
|
|
253
|
+
None,
|
|
254
|
+
)
|
|
255
|
+
if selected is None:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
settings = selected.get("settings") if isinstance(selected.get("settings"), dict) else {}
|
|
259
|
+
out = {
|
|
260
|
+
"provider": selected.get("provider"),
|
|
261
|
+
"connection_string_name": settings.get("connection_string_name"),
|
|
262
|
+
"container_name": settings.get("container_name"),
|
|
263
|
+
"integration": selected.get("name"),
|
|
264
|
+
}
|
|
265
|
+
return {k: v for k, v in out.items() if v is not None}
|
|
266
|
+
|
|
267
|
+
def _resolve_packages(self, svc: dict):
|
|
268
|
+
defaults = self.config.get("defaults", {}).get("packages", {}) or {}
|
|
269
|
+
service_packages = svc.get("packages", {}) or {}
|
|
270
|
+
|
|
271
|
+
merged = _deep_merge(defaults, service_packages)
|
|
272
|
+
|
|
273
|
+
# A service that declares a database integration/provider implicitly turns
|
|
274
|
+
# on the db_provider package and feeds it connection settings, unless the
|
|
275
|
+
# package is explicitly disabled.
|
|
276
|
+
db = self._service_database(svc)
|
|
277
|
+
if db and "db_provider" in self.packages.available():
|
|
278
|
+
existing = merged.get("db_provider")
|
|
279
|
+
if not isinstance(existing, dict) or existing.get("enabled") is not False:
|
|
280
|
+
pkg_cfg = dict(existing) if isinstance(existing, dict) else {}
|
|
281
|
+
pkg_cfg.setdefault("enabled", True)
|
|
282
|
+
pkg_cfg.setdefault("db_type", db.get("type"))
|
|
283
|
+
if db.get("store"):
|
|
284
|
+
pkg_cfg.setdefault("provider", db["store"])
|
|
285
|
+
if db.get("connection_string_name"):
|
|
286
|
+
pkg_cfg.setdefault("connection_string_name", db["connection_string_name"])
|
|
287
|
+
merged["db_provider"] = pkg_cfg
|
|
288
|
+
|
|
289
|
+
# A service that declares a storage integration/provider implicitly turns
|
|
290
|
+
# on the Azure file-storage package, unless explicitly disabled.
|
|
291
|
+
storage = self._service_storage(svc)
|
|
292
|
+
if storage and "file_storage_azure" in self.packages.available():
|
|
293
|
+
existing = merged.get("file_storage_azure")
|
|
294
|
+
if not isinstance(existing, dict) or existing.get("enabled") is not False:
|
|
295
|
+
pkg_cfg = dict(existing) if isinstance(existing, dict) else {}
|
|
296
|
+
pkg_cfg.setdefault("enabled", True)
|
|
297
|
+
merged["file_storage_azure"] = pkg_cfg
|
|
298
|
+
|
|
299
|
+
return self.packages.resolve_enabled(merged)
|
|
300
|
+
|
|
301
|
+
def _service_base_ns(self, svc: dict) -> str:
|
|
302
|
+
return naming.namespace(self.namespace_root, svc["name"])
|
|
303
|
+
|
|
304
|
+
# ---- database adapter resolution (configurable per store) ----------- #
|
|
305
|
+
_STORE_KEY_ALIASES = {
|
|
306
|
+
"sqlserver": "mssql",
|
|
307
|
+
"sql_server": "mssql",
|
|
308
|
+
"mssql": "mssql",
|
|
309
|
+
"postgres": "postgres",
|
|
310
|
+
"postgresql": "postgres",
|
|
311
|
+
"cassandra": "cassandra",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def _normalize_store_key(cls, store: str) -> str:
|
|
316
|
+
key = str(store or "").strip().lower().replace("-", "_")
|
|
317
|
+
return cls._STORE_KEY_ALIASES.get(key, key)
|
|
318
|
+
|
|
319
|
+
def _resolve_db_adapter(self, db_settings: dict | None, database: dict | None) -> dict:
|
|
320
|
+
"""Pick the adapter descriptor for the service's store.
|
|
321
|
+
|
|
322
|
+
Adapters live in the db_provider package config (``adapters:``) so a new
|
|
323
|
+
engine is added by editing config, not Python. Resolution order:
|
|
324
|
+
exact store key -> relational_default / non_relational_default by type.
|
|
325
|
+
"""
|
|
326
|
+
adapters = (db_settings or {}).get("adapters", {}) or {}
|
|
327
|
+
store = (database or {}).get("store") or (database or {}).get("provider") \
|
|
328
|
+
or (db_settings or {}).get("provider") or ""
|
|
329
|
+
db_type = (database or {}).get("type") or (db_settings or {}).get("db_type") or "non_relational"
|
|
330
|
+
|
|
331
|
+
key = self._normalize_store_key(store)
|
|
332
|
+
adapter = adapters.get(key)
|
|
333
|
+
if not isinstance(adapter, dict):
|
|
334
|
+
fallback = "relational_default" if db_type == "relational" else "non_relational_default"
|
|
335
|
+
adapter = adapters.get(fallback)
|
|
336
|
+
adapter = dict(adapter) if isinstance(adapter, dict) else {}
|
|
337
|
+
adapter.setdefault(
|
|
338
|
+
"dbtype_enum_value", "Relational" if db_type == "relational" else "NonRelational"
|
|
339
|
+
)
|
|
340
|
+
return adapter
|
|
341
|
+
|
|
342
|
+
def _base_context(self, svc: dict) -> dict:
|
|
343
|
+
resolved = self._resolve_packages(svc)
|
|
344
|
+
obs = next((s for p, s in resolved if p.key == "observability"), None)
|
|
345
|
+
db_pkg, db_settings = next(
|
|
346
|
+
((p, s) for p, s in resolved if p.key == "db_provider"), (None, None)
|
|
347
|
+
)
|
|
348
|
+
database = self._service_database(svc)
|
|
349
|
+
endpoints = [HEALTH_ENDPOINT] + svc.get("endpoints", [])
|
|
350
|
+
ns = base.resolve_namespaces(self.layout.get("namespaces", {}), self._service_base_ns(svc))
|
|
351
|
+
|
|
352
|
+
# Resolve the database adapter (by store, configurable) and expose the
|
|
353
|
+
# runtime schema-config key + facade response type on db_settings, so the
|
|
354
|
+
# DI fragment and provider templates stay declarative. Mutated in place
|
|
355
|
+
# because the same dict is what the Program.cs fragment receives.
|
|
356
|
+
response_type = "ExternalizationResponse"
|
|
357
|
+
if isinstance(db_settings, dict):
|
|
358
|
+
adapter = self._resolve_db_adapter(db_settings, database)
|
|
359
|
+
db_settings["adapter"] = adapter
|
|
360
|
+
relational_cfg = db_settings.get("relational") if isinstance(db_settings.get("relational"), dict) else {}
|
|
361
|
+
db_settings["schema_config_key"] = (
|
|
362
|
+
relational_cfg.get("schema_name_config_key")
|
|
363
|
+
or "DB_Provider:RelationalDatabase:SqlServer:SchemaName"
|
|
364
|
+
)
|
|
365
|
+
response_type = db_settings.setdefault("response_type", response_type)
|
|
366
|
+
|
|
367
|
+
# Normalise providers so templates have a stable, provider-agnostic
|
|
368
|
+
# shape. Provider kind, not the presence of methods, decides behaviour.
|
|
369
|
+
integrations = self._service_integrations(svc)
|
|
370
|
+
providers = []
|
|
371
|
+
for p in svc.get("providers", []):
|
|
372
|
+
p = dict(p)
|
|
373
|
+
integration = integrations.get(p.get("integration"))
|
|
374
|
+
provider_kind = str(p.get("kind") or (integration or {}).get("kind") or "custom").lower()
|
|
375
|
+
p["kind"] = provider_kind
|
|
376
|
+
if integration and integration.get("provider") and not p.get("provider"):
|
|
377
|
+
p["provider"] = integration["provider"]
|
|
378
|
+
methods = p.get("methods", []) or []
|
|
379
|
+
p["methods"] = [self._normalise_method(m, provider_kind, database) for m in methods]
|
|
380
|
+
# For relational (stored-procedure) methods the first cut returns the
|
|
381
|
+
# facade response verbatim; the configured `returns` is kept for the
|
|
382
|
+
# later mapping layer but does not drive the signature.
|
|
383
|
+
for m in p["methods"]:
|
|
384
|
+
m["return_type_cs"] = (
|
|
385
|
+
response_type if m.get("relational")
|
|
386
|
+
else naming.csharp_type(m.get("returns") or "object")
|
|
387
|
+
)
|
|
388
|
+
p["has_methods"] = bool(p["methods"])
|
|
389
|
+
p["has_relational"] = any(m.get("relational") for m in p["methods"])
|
|
390
|
+
p["is_db"] = provider_kind == "database"
|
|
391
|
+
providers.append(p)
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
"config": self.config,
|
|
395
|
+
"project": self.project,
|
|
396
|
+
"svc": svc,
|
|
397
|
+
"service_name": svc["name"],
|
|
398
|
+
"service": svc["name"],
|
|
399
|
+
"dotnet": self.dotnet,
|
|
400
|
+
"ns": ns,
|
|
401
|
+
"endpoints": endpoints,
|
|
402
|
+
"business_endpoints": [e for e in endpoints if not e.get("is_health")],
|
|
403
|
+
"models": svc.get("models", []),
|
|
404
|
+
"dtos": svc.get("dtos", []),
|
|
405
|
+
"entities": svc.get("entities", []),
|
|
406
|
+
"bos": svc.get("bos", []),
|
|
407
|
+
"providers": providers,
|
|
408
|
+
"db_providers": [p for p in providers if p["is_db"]],
|
|
409
|
+
"database": database,
|
|
410
|
+
"db_enabled": database is not None,
|
|
411
|
+
"db_settings": db_settings or {},
|
|
412
|
+
"resolved_packages": resolved,
|
|
413
|
+
"obs": obs,
|
|
414
|
+
"obs_enabled": obs is not None,
|
|
415
|
+
"controller_style": self.layout.get("controller_style", "service_manager"),
|
|
416
|
+
"use_service_locator": self.layout.get("controller_style", "service_manager") == "service_manager",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _normalise_method(method: dict, provider_kind: str, database: dict | None) -> dict:
|
|
421
|
+
m = dict(method)
|
|
422
|
+
settings = dict(m.get("settings") or {})
|
|
423
|
+
if isinstance(m.get("database"), dict):
|
|
424
|
+
settings = {**m["database"], **settings}
|
|
425
|
+
|
|
426
|
+
# Backward-compatible aliases are consumed into the common method shape.
|
|
427
|
+
for key in ("stored_procedure", "target", "table", "inputs", "parameters", "cursors"):
|
|
428
|
+
if key in m and key not in settings:
|
|
429
|
+
settings[key] = m[key]
|
|
430
|
+
|
|
431
|
+
input_entity = m.get("input_entity") or m.get("entity")
|
|
432
|
+
output_entity = m.get("output_entity")
|
|
433
|
+
|
|
434
|
+
m["input_entity"] = input_entity
|
|
435
|
+
m["entity"] = input_entity # internal template convenience only
|
|
436
|
+
m["output_entity"] = output_entity
|
|
437
|
+
m["settings"] = settings
|
|
438
|
+
|
|
439
|
+
if not m.get("returns"):
|
|
440
|
+
m["returns"] = output_entity or "object"
|
|
441
|
+
|
|
442
|
+
op = str(m.get("operation") or "").strip().replace("-", "_").replace(" ", "_").lower()
|
|
443
|
+
if op in {"storedprocedure", "stored_proc", "sp"}:
|
|
444
|
+
op = "stored_procedure"
|
|
445
|
+
elif op in {"getresults", "get_results"}:
|
|
446
|
+
op = "query"
|
|
447
|
+
elif not op and provider_kind == "database":
|
|
448
|
+
op = "stored_procedure" if settings.get("stored_procedure") else "query"
|
|
449
|
+
m["operation"] = op or m.get("operation") or "execute"
|
|
450
|
+
|
|
451
|
+
if provider_kind == "database":
|
|
452
|
+
default_db_type = (database or {}).get("type", "non_relational")
|
|
453
|
+
method_kind = settings.get("method_type") or m.get("kind")
|
|
454
|
+
if method_kind not in {"relational", "non_relational"}:
|
|
455
|
+
method_kind = "relational" if m["operation"] == "stored_procedure" else default_db_type
|
|
456
|
+
if method_kind not in {"relational", "non_relational"}:
|
|
457
|
+
method_kind = "non_relational"
|
|
458
|
+
|
|
459
|
+
m["kind"] = method_kind
|
|
460
|
+
m["relational"] = method_kind == "relational"
|
|
461
|
+
m["db_type_enum"] = "Relational" if method_kind == "relational" else "NonRelational"
|
|
462
|
+
m["facade_operation"] = "StoredProcedure" if m["operation"] == "stored_procedure" or method_kind == "relational" else "Query"
|
|
463
|
+
m["target"] = settings.get("target") or settings.get("stored_procedure") or m.get("target") or (m.get("name") if method_kind == "relational" else "GetResults")
|
|
464
|
+
m["stored_procedure"] = settings.get("stored_procedure") or (m["target"] if method_kind == "relational" else None)
|
|
465
|
+
m["table"] = settings.get("table")
|
|
466
|
+
m["inputs"] = settings.get("inputs", []) or []
|
|
467
|
+
m["parameters"] = settings.get("parameters", []) or []
|
|
468
|
+
# Non-relational extras (Cassandra-style facade requests).
|
|
469
|
+
# extended_context_action -> emits ExtendedContext = new CassandraExtendedContext { Action = "insert"|"update" }
|
|
470
|
+
m["extended_context_action"] = settings.get("extended_context_action")
|
|
471
|
+
m["extended_context_type"] = settings.get("extended_context_type", "CassandraExtendedContext")
|
|
472
|
+
else:
|
|
473
|
+
m["kind"] = provider_kind
|
|
474
|
+
m["relational"] = False
|
|
475
|
+
m.setdefault("inputs", [])
|
|
476
|
+
m.setdefault("parameters", [])
|
|
477
|
+
|
|
478
|
+
return m
|
|
479
|
+
|
|
480
|
+
# ---- generation ----------------------------------------------------- #
|
|
481
|
+
def generate(self, filter: GenerationFilter | None = None) -> list[Path]:
|
|
482
|
+
flt = filter or GenerationFilter()
|
|
483
|
+
written: list[Path] = []
|
|
484
|
+
self.solution_dir.mkdir(parents=True, exist_ok=True)
|
|
485
|
+
sln_projects: list[dict] = []
|
|
486
|
+
# Collect per-artifact failures instead of letting the first one abort
|
|
487
|
+
# the whole run. A bad provider template must never cost you DTOs,
|
|
488
|
+
# entities or models that are generated independently of it.
|
|
489
|
+
self.errors: list[str] = []
|
|
490
|
+
|
|
491
|
+
for svc in self.config.get("services", []):
|
|
492
|
+
if not flt.allow_service(svc["name"]):
|
|
493
|
+
continue
|
|
494
|
+
ctx = self._base_context(svc)
|
|
495
|
+
for proj in self.layout["projects"]:
|
|
496
|
+
paths, sln_entry = self._generate_project(svc, proj, ctx, flt)
|
|
497
|
+
written += paths
|
|
498
|
+
sln_projects.append(sln_entry)
|
|
499
|
+
|
|
500
|
+
# Don't rewrite solution-level files during a filtered (partial) run.
|
|
501
|
+
if not flt.active:
|
|
502
|
+
written.append(self._write_solution(sln_projects))
|
|
503
|
+
written.append(self._write_config_copy())
|
|
504
|
+
|
|
505
|
+
if self.errors:
|
|
506
|
+
# Everything that could be written has been written; surface the
|
|
507
|
+
# failures together so the user sees which artifacts need attention
|
|
508
|
+
# rather than a single traceback that hid the rest.
|
|
509
|
+
summary = "\n".join(f" - {e}" for e in self.errors)
|
|
510
|
+
raise RuntimeError(
|
|
511
|
+
f"{len(self.errors)} artifact(s) failed to generate "
|
|
512
|
+
f"(the rest were still written):\n{summary}"
|
|
513
|
+
)
|
|
514
|
+
return written
|
|
515
|
+
|
|
516
|
+
def _project_dir(self, svc, proj) -> Path:
|
|
517
|
+
rel = proj["dir"].format(service=svc["name"], ns=self._service_base_ns(svc))
|
|
518
|
+
return self.solution_dir / rel
|
|
519
|
+
|
|
520
|
+
def _generate_project(self, svc, proj, base_ctx, flt) -> tuple[list[Path], dict]:
|
|
521
|
+
pdir = self._project_dir(svc, proj)
|
|
522
|
+
proj_ns = proj["namespace"].format(ns=self._service_base_ns(svc))
|
|
523
|
+
is_host = proj.get("host", False)
|
|
524
|
+
|
|
525
|
+
# project-to-project references
|
|
526
|
+
refs = []
|
|
527
|
+
for ref_id in proj.get("references", []):
|
|
528
|
+
ref_proj = next(p for p in self.layout["projects"] if p["id"] == ref_id)
|
|
529
|
+
ref_dir = self._project_dir(svc, ref_proj)
|
|
530
|
+
ref_name = ref_proj["csproj"]["path"].format(
|
|
531
|
+
service=svc["name"], ns=self._service_base_ns(svc)
|
|
532
|
+
)
|
|
533
|
+
rel = os.path.relpath(ref_dir / ref_name, start=pdir)
|
|
534
|
+
refs.append({"path": rel.replace("/", "\\")})
|
|
535
|
+
|
|
536
|
+
# nuget for this project
|
|
537
|
+
nuget_inputs = list(proj.get("nuget", []))
|
|
538
|
+
has_provider_artifacts = any(
|
|
539
|
+
art.get("template") in {"Provider.cs.j2", "DbProviderBase.cs.j2", "ProviderBase.cs.j2"}
|
|
540
|
+
for art in proj.get("artifacts", [])
|
|
541
|
+
)
|
|
542
|
+
if is_host or has_provider_artifacts:
|
|
543
|
+
for pkg, _ in base_ctx["resolved_packages"]:
|
|
544
|
+
# Host projects need package NuGets for DI fragments. Provider
|
|
545
|
+
# projects need package NuGets for any generated/provider-specific
|
|
546
|
+
# scaffolds, not only database scaffolds.
|
|
547
|
+
nuget_inputs += [{"name": r.name, "version": r.version} for r in pkg.nuget]
|
|
548
|
+
if is_host:
|
|
549
|
+
nuget_inputs += self.config.get("defaults", {}).get("nuget", [])
|
|
550
|
+
nuget_inputs += svc.get("nuget", [])
|
|
551
|
+
nuget_refs = base.resolve_nuget_refs(
|
|
552
|
+
nuget_inputs,
|
|
553
|
+
config_versions=self._package_versions_for_service(svc),
|
|
554
|
+
version_strategy=self.version_strategy,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
proj_ctx = dict(base_ctx)
|
|
558
|
+
proj_ctx.update({
|
|
559
|
+
"project_namespace": proj_ns,
|
|
560
|
+
"project_id": proj["id"],
|
|
561
|
+
"is_host": is_host,
|
|
562
|
+
"project_references": refs,
|
|
563
|
+
"nuget_refs": nuget_refs,
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
# deterministic GUID from solution + project path
|
|
567
|
+
guid = str(uuid.uuid5(_GUID_NS, f"{self.solution_name}/{proj_ns}")).upper()
|
|
568
|
+
|
|
569
|
+
csproj = proj["csproj"]
|
|
570
|
+
csproj_name = csproj["path"].format(service=svc["name"], ns=self._service_base_ns(svc))
|
|
571
|
+
|
|
572
|
+
written: list[Path] = []
|
|
573
|
+
# csproj counts as the "project" layer; skip on a filtered layer run that
|
|
574
|
+
# excludes it (but never skip on a service-only filter).
|
|
575
|
+
if flt.allow_layer("csproj"):
|
|
576
|
+
content = self.env.get_template(csproj["template"]).render(**proj_ctx)
|
|
577
|
+
path, _ = base.write_file(pdir / csproj_name, content)
|
|
578
|
+
written.append(path)
|
|
579
|
+
|
|
580
|
+
for art in proj.get("artifacts", []):
|
|
581
|
+
layer = _artifact_layer(art)
|
|
582
|
+
if not flt.allow_layer(layer):
|
|
583
|
+
continue
|
|
584
|
+
try:
|
|
585
|
+
written += self._generate_artifact(svc, pdir, art, proj_ctx, flt)
|
|
586
|
+
except Exception as exc: # noqa: BLE001 - isolate one artifact's failure
|
|
587
|
+
label = art.get("template") or art.get("hook") or layer
|
|
588
|
+
getattr(self, "errors", self.__dict__.setdefault("errors", [])).append(
|
|
589
|
+
f"{svc['name']} / {label} ({layer}): {type(exc).__name__}: {exc}"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
sln_entry = {
|
|
593
|
+
"name": csproj_name.rsplit(".", 1)[0] if csproj_name.endswith(".csproj") else csproj_name,
|
|
594
|
+
"path": str((pdir.relative_to(self.solution_dir)) / csproj_name).replace("/", "\\"),
|
|
595
|
+
"guid": guid,
|
|
596
|
+
}
|
|
597
|
+
return written, sln_entry
|
|
598
|
+
|
|
599
|
+
def _generate_artifact(self, svc, pdir, art, proj_ctx, flt) -> list[Path]:
|
|
600
|
+
scope = art.get("scope", "once")
|
|
601
|
+
owner = art.get("owner", "framework")
|
|
602
|
+
# Selective regen with --force overwrites write-once (user) files too.
|
|
603
|
+
if owner == "user" and flt.force:
|
|
604
|
+
owner = "framework"
|
|
605
|
+
|
|
606
|
+
if scope == "once":
|
|
607
|
+
loop_items = [None]
|
|
608
|
+
else:
|
|
609
|
+
collection_key, _ = _SCOPE_KEYS[scope]
|
|
610
|
+
loop_items = proj_ctx[collection_key]
|
|
611
|
+
|
|
612
|
+
written = []
|
|
613
|
+
for item in loop_items:
|
|
614
|
+
ctx = dict(proj_ctx)
|
|
615
|
+
fmt = {"service": svc["name"], "ns": proj_ctx["ns"]["base"]}
|
|
616
|
+
if scope != "once":
|
|
617
|
+
_, var_name = _SCOPE_KEYS[scope]
|
|
618
|
+
ctx[var_name] = item
|
|
619
|
+
fmt[var_name] = item.get("name", "") if isinstance(item, dict) else item
|
|
620
|
+
|
|
621
|
+
# per-provider artifacts that gate on db-only providers
|
|
622
|
+
if art.get("only") == "db_provider" and not (isinstance(item, dict) and item.get("is_db")):
|
|
623
|
+
continue
|
|
624
|
+
|
|
625
|
+
out_path = pdir / art["path"].format(**fmt)
|
|
626
|
+
|
|
627
|
+
if "hook" in art:
|
|
628
|
+
content = self._run_hook(art["hook"], ctx)
|
|
629
|
+
else:
|
|
630
|
+
content = self.env.get_template(art["template"]).render(**ctx)
|
|
631
|
+
|
|
632
|
+
path, _ = base.write_file(out_path, content, owner=owner)
|
|
633
|
+
written.append(path)
|
|
634
|
+
return written
|
|
635
|
+
|
|
636
|
+
# ---- hooks (the only place a new building block needs Python) ------- #
|
|
637
|
+
def _run_hook(self, name: str, ctx: dict) -> str:
|
|
638
|
+
if name == "program":
|
|
639
|
+
return self._hook_program(ctx)
|
|
640
|
+
if name == "appsettings":
|
|
641
|
+
return self._hook_appsettings(ctx)
|
|
642
|
+
if name == "launch":
|
|
643
|
+
return self._hook_launch(ctx)
|
|
644
|
+
raise ValueError(f"Unknown manifest hook '{name}'")
|
|
645
|
+
|
|
646
|
+
def _hook_program(self, ctx: dict) -> str:
|
|
647
|
+
fragments = []
|
|
648
|
+
app_fragments = []
|
|
649
|
+
for pkg, settings in ctx["resolved_packages"]:
|
|
650
|
+
if pkg.program_fragment:
|
|
651
|
+
rendered = self.env.from_string(pkg.program_fragment).render(
|
|
652
|
+
settings=settings, **ctx
|
|
653
|
+
).strip()
|
|
654
|
+
if rendered:
|
|
655
|
+
fragments.append(f"// --- {pkg.display_name} ---\n{rendered}")
|
|
656
|
+
if pkg.app_fragment:
|
|
657
|
+
rendered = self.env.from_string(pkg.app_fragment).render(
|
|
658
|
+
settings=settings, **ctx
|
|
659
|
+
).strip()
|
|
660
|
+
if rendered:
|
|
661
|
+
app_fragments.append(f"// --- {pkg.display_name} ---\n{rendered}")
|
|
662
|
+
return self.env.get_template("Program.cs.j2").render(
|
|
663
|
+
package_registrations=fragments,
|
|
664
|
+
app_registrations=app_fragments,
|
|
665
|
+
**ctx,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
def _merged_appsettings(self, ctx: dict) -> dict:
|
|
669
|
+
"""Build the fully-merged appsettings dict for a service.
|
|
670
|
+
|
|
671
|
+
Used both to render ``appsettings.json`` and to project every setting
|
|
672
|
+
into ``launchSettings.json`` as ``__``-nested environment variables.
|
|
673
|
+
"""
|
|
674
|
+
out = {
|
|
675
|
+
"ServiceName": ctx["service_name"],
|
|
676
|
+
"Logging": {"LogLevel": {"Default": "Information", "Microsoft.AspNetCore": "Warning"}},
|
|
677
|
+
"AllowedHosts": "*",
|
|
678
|
+
}
|
|
679
|
+
out = merge_appsettings(out, json.dumps(ctx["svc"].get("appsettings", {})))
|
|
680
|
+
for pkg, settings in ctx["resolved_packages"]:
|
|
681
|
+
if pkg.appsettings_fragment:
|
|
682
|
+
frag = self.env.from_string(pkg.appsettings_fragment).render(settings=settings, **ctx)
|
|
683
|
+
if frag.strip():
|
|
684
|
+
out = merge_appsettings(out, frag)
|
|
685
|
+
return out
|
|
686
|
+
|
|
687
|
+
def _hook_appsettings(self, ctx: dict) -> str:
|
|
688
|
+
return json.dumps(self._merged_appsettings(ctx), indent=2)
|
|
689
|
+
|
|
690
|
+
def _hook_launch(self, ctx: dict) -> str:
|
|
691
|
+
"""Render launchSettings.json with every appsetting mirrored into the
|
|
692
|
+
profile's ``environmentVariables`` using the .NET ``__`` hierarchy
|
|
693
|
+
separator, so a developer running locally gets the same configuration
|
|
694
|
+
the app would read from appsettings without editing JSON by hand."""
|
|
695
|
+
merged = self._merged_appsettings(ctx)
|
|
696
|
+
env_vars = {"ASPNETCORE_ENVIRONMENT": "Development"}
|
|
697
|
+
env_vars.update(flatten_appsettings(merged))
|
|
698
|
+
launch_ctx = dict(ctx)
|
|
699
|
+
launch_ctx["launch_env_vars"] = env_vars
|
|
700
|
+
return self.env.get_template("launchSettings.json.j2").render(**launch_ctx)
|
|
701
|
+
|
|
702
|
+
# ---- solution + config copy ----------------------------------------- #
|
|
703
|
+
def _write_solution(self, projects: list[dict]) -> Path:
|
|
704
|
+
content = self.env.get_template(
|
|
705
|
+
self.layout.get("solution", {}).get("template", "Solution.sln.j2")
|
|
706
|
+
).render(projects=projects, config=self.config)
|
|
707
|
+
path, _ = base.write_file(self.solution_dir / f"{self.solution_name}.sln", content)
|
|
708
|
+
return path
|
|
709
|
+
|
|
710
|
+
def _write_config_copy(self) -> Path:
|
|
711
|
+
path, _ = base.write_file(
|
|
712
|
+
self.solution_dir / "nd-config.yaml",
|
|
713
|
+
yaml.dump(self.config, sort_keys=False, indent=2),
|
|
714
|
+
)
|
|
715
|
+
return path
|