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
ndsdk/main.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""ND-SDK CLI — entrypoint.
|
|
2
|
+
|
|
3
|
+
Commands mirror the Python reference (init / generate / validate / preview) plus
|
|
4
|
+
two introspection commands (packages / architectures) that expose what the CLI
|
|
5
|
+
maintainers have made available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .config_normalizer import normalize_config
|
|
19
|
+
from .generators import available_architectures, available_variants, get_generator
|
|
20
|
+
from .generators.engine import GenerationFilter, available_layers
|
|
21
|
+
from .registry import PackageRegistry
|
|
22
|
+
from .validators import ConfigValidator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option()
|
|
27
|
+
def cli():
|
|
28
|
+
"""ND-SDK — metadata-driven C# scaffolding generator."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
def _ask_loop(label, build, *, first_default=True):
|
|
33
|
+
"""Repeatedly run build() while the user wants to add more of `label`."""
|
|
34
|
+
items = []
|
|
35
|
+
while click.confirm(f" Add {'an' if label[0] in 'aeiou' else 'a'} {label}?",
|
|
36
|
+
default=first_default if not items else False):
|
|
37
|
+
item = build()
|
|
38
|
+
if item is not None:
|
|
39
|
+
items.append(item)
|
|
40
|
+
return items
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _prompt_optional(label, default=""):
|
|
44
|
+
val = click.prompt(label, default=default, show_default=bool(default))
|
|
45
|
+
return val or None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_field():
|
|
49
|
+
name = click.prompt(" Field name")
|
|
50
|
+
ftype = click.prompt(" Type (string, int, bool, list<Foo>, dict<string,int>, Foo[])",
|
|
51
|
+
default="string")
|
|
52
|
+
nullable = click.confirm(" Nullable?", default=False)
|
|
53
|
+
json_name = _prompt_optional(" JSON property name (blank = none)")
|
|
54
|
+
f = {"name": name, "type": ftype}
|
|
55
|
+
if nullable:
|
|
56
|
+
f["nullable"] = True
|
|
57
|
+
if json_name:
|
|
58
|
+
f["json_name"] = json_name
|
|
59
|
+
return f
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_model():
|
|
63
|
+
name = click.prompt(" Model name")
|
|
64
|
+
fields = []
|
|
65
|
+
while click.confirm(f" Add a field to {name}?", default=True):
|
|
66
|
+
fields.append(_build_field())
|
|
67
|
+
return {"name": name, "fields": fields}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_provider(entity_names):
|
|
71
|
+
name = click.prompt(" Provider name")
|
|
72
|
+
kind = click.prompt(
|
|
73
|
+
" Provider kind",
|
|
74
|
+
type=click.Choice(["custom", "database", "storage", "queue", "http_api", "cache", "ai_model"], case_sensitive=False),
|
|
75
|
+
default="custom",
|
|
76
|
+
).lower()
|
|
77
|
+
integration = _prompt_optional(" Integration name this provider uses (blank = none)")
|
|
78
|
+
|
|
79
|
+
prov = {"name": name, "kind": kind}
|
|
80
|
+
if integration:
|
|
81
|
+
prov["integration"] = integration
|
|
82
|
+
|
|
83
|
+
if not click.confirm(f" Add methods to {name}?", default=(kind == "database")):
|
|
84
|
+
base = click.prompt(" Base class", default="ProviderBase")
|
|
85
|
+
prov["base"] = base
|
|
86
|
+
return prov
|
|
87
|
+
|
|
88
|
+
prov["methods"] = []
|
|
89
|
+
while click.confirm(f" Add a method to {name}?", default=True):
|
|
90
|
+
if kind == "database":
|
|
91
|
+
prov["methods"].append(_build_db_method(entity_names))
|
|
92
|
+
else:
|
|
93
|
+
prov["methods"].append(_build_generic_method(entity_names, kind))
|
|
94
|
+
return prov
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_generic_method(entity_names, provider_kind):
|
|
98
|
+
mname = click.prompt(" Method name")
|
|
99
|
+
operation = click.prompt(" Operation", default="execute")
|
|
100
|
+
returns = click.prompt(" Return type", default="object")
|
|
101
|
+
hint = f" (defined: {', '.join(entity_names)})" if entity_names else ""
|
|
102
|
+
entity = _prompt_optional(f" Input entity{hint} (blank = none)")
|
|
103
|
+
m = {"name": mname, "operation": operation, "returns": returns, "settings": {}}
|
|
104
|
+
if entity:
|
|
105
|
+
m["input_entity"] = entity
|
|
106
|
+
return m
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_db_method(entity_names):
|
|
110
|
+
mname = click.prompt(" Method name")
|
|
111
|
+
operation = click.prompt(
|
|
112
|
+
" Operation",
|
|
113
|
+
type=click.Choice(["query", "stored_procedure"], case_sensitive=False),
|
|
114
|
+
default="query",
|
|
115
|
+
).lower()
|
|
116
|
+
returns = click.prompt(" Return type", default="object")
|
|
117
|
+
hint = f" (defined: {', '.join(entity_names)})" if entity_names else ""
|
|
118
|
+
entity = _prompt_optional(f" Input entity{hint} (blank = none)")
|
|
119
|
+
m = {"name": mname, "operation": operation, "returns": returns, "settings": {}}
|
|
120
|
+
if entity:
|
|
121
|
+
m["input_entity"] = entity
|
|
122
|
+
|
|
123
|
+
if operation == "stored_procedure":
|
|
124
|
+
m["settings"]["stored_procedure"] = click.prompt(" Stored procedure name")
|
|
125
|
+
params = []
|
|
126
|
+
while click.confirm(" Add a parameter?", default=True):
|
|
127
|
+
pname = click.prompt(" Parameter name")
|
|
128
|
+
direction = click.prompt(" Direction",
|
|
129
|
+
type=click.Choice(["in", "out", "cursor"]), default="in")
|
|
130
|
+
param = {"name": pname, "direction": direction}
|
|
131
|
+
if direction == "in" and entity:
|
|
132
|
+
ef = _prompt_optional(" Bind from entity field (blank = none)")
|
|
133
|
+
if ef:
|
|
134
|
+
param["entity_field"] = ef
|
|
135
|
+
if direction == "out":
|
|
136
|
+
ptype = _prompt_optional(" DB type (e.g. Int, VarChar; blank = adapter default)")
|
|
137
|
+
if ptype:
|
|
138
|
+
param["type"] = ptype
|
|
139
|
+
if ptype and ptype.lower() in {"varchar", "nvarchar", "varbinary", "char"}:
|
|
140
|
+
size = _prompt_optional(" Size (blank = none)")
|
|
141
|
+
if size and size.isdigit():
|
|
142
|
+
param["size"] = int(size)
|
|
143
|
+
if direction == "cursor":
|
|
144
|
+
param["cursor_name"] = click.prompt(" Cursor name", default=f"{pname}_cursor")
|
|
145
|
+
params.append(param)
|
|
146
|
+
m["settings"]["parameters"] = params
|
|
147
|
+
else:
|
|
148
|
+
m["settings"]["target"] = click.prompt(" Target", default="GetResults")
|
|
149
|
+
m["settings"]["table"] = click.prompt(" Table name")
|
|
150
|
+
inputs = []
|
|
151
|
+
while click.confirm(" Add an input parameter?", default=True):
|
|
152
|
+
key = click.prompt(" Key (e.g. PrimaryValue)")
|
|
153
|
+
if entity and click.confirm(" Bind from entity field?", default=True):
|
|
154
|
+
inputs.append({"key": key, "entity_field": click.prompt(" Entity field")})
|
|
155
|
+
else:
|
|
156
|
+
inputs.append({"key": key, "value": click.prompt(" Literal value", default="")})
|
|
157
|
+
m["settings"]["inputs"] = inputs
|
|
158
|
+
return m
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build_entity():
|
|
162
|
+
name = click.prompt(" Entity name")
|
|
163
|
+
fields = []
|
|
164
|
+
while click.confirm(f" Add a field to {name}?", default=True):
|
|
165
|
+
fields.append(_build_field())
|
|
166
|
+
return {"name": name, "fields": fields}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_bo():
|
|
170
|
+
name = click.prompt(" BO name")
|
|
171
|
+
fields = []
|
|
172
|
+
while click.confirm(f" Add a field to {name}?", default=False):
|
|
173
|
+
fields.append(_build_field())
|
|
174
|
+
bo = {"name": name}
|
|
175
|
+
if fields:
|
|
176
|
+
bo["fields"] = fields
|
|
177
|
+
return bo
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_endpoint(model_names):
|
|
181
|
+
name = click.prompt(" Endpoint name")
|
|
182
|
+
method = click.prompt(
|
|
183
|
+
" HTTP method",
|
|
184
|
+
type=click.Choice(["GET", "POST", "PUT", "DELETE", "PATCH"], case_sensitive=False),
|
|
185
|
+
default="POST",
|
|
186
|
+
).upper()
|
|
187
|
+
route = click.prompt(" Route", default=f"api/v1/{name}")
|
|
188
|
+
summary = _prompt_optional(" Summary", default=f"{name} endpoint")
|
|
189
|
+
description = _prompt_optional(" Description (blank = none)")
|
|
190
|
+
|
|
191
|
+
ep = {"name": name, "http_method": method, "route": route}
|
|
192
|
+
if summary:
|
|
193
|
+
ep["summary"] = summary
|
|
194
|
+
if description:
|
|
195
|
+
ep["description"] = description
|
|
196
|
+
|
|
197
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
198
|
+
consumes = click.prompt(
|
|
199
|
+
" Consumes",
|
|
200
|
+
type=click.Choice(["application/json", "multipart/form-data", "none"]),
|
|
201
|
+
default="application/json",
|
|
202
|
+
)
|
|
203
|
+
if consumes != "none":
|
|
204
|
+
ep["consumes"] = consumes
|
|
205
|
+
if consumes == "multipart/form-data":
|
|
206
|
+
ep["request_field"] = click.prompt(" Form field holding the JSON payload",
|
|
207
|
+
default="inputobj")
|
|
208
|
+
|
|
209
|
+
hint = f" (defined: {', '.join(model_names)})" if model_names else ""
|
|
210
|
+
req = _prompt_optional(f" Request model{hint}")
|
|
211
|
+
if req:
|
|
212
|
+
ep["request_model"] = req
|
|
213
|
+
resp = _prompt_optional(f" Response model{hint}")
|
|
214
|
+
if resp:
|
|
215
|
+
ep["response_model"] = resp
|
|
216
|
+
ep["service_method"] = click.prompt(" Service method name",
|
|
217
|
+
default="".join(w.capitalize() for w in
|
|
218
|
+
name.replace("-", " ").replace("_", " ").split()))
|
|
219
|
+
return ep
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _prompt_packages(registry, *, indent=" "):
|
|
223
|
+
"""Enable packages and collect their user-facing settings."""
|
|
224
|
+
enabled = {}
|
|
225
|
+
for key in registry.available():
|
|
226
|
+
pkg = registry.get(key)
|
|
227
|
+
if click.confirm(f"{indent}Enable {pkg.display_name}?",
|
|
228
|
+
default=pkg.defaults.get("enabled", False)):
|
|
229
|
+
settings = {"enabled": True}
|
|
230
|
+
for spec in pkg.interactive:
|
|
231
|
+
if spec.get("choices"):
|
|
232
|
+
val = click.prompt(
|
|
233
|
+
f"{indent} {spec.get('prompt', spec['key'])}",
|
|
234
|
+
type=click.Choice(spec["choices"]),
|
|
235
|
+
default=spec.get("default"),
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
val = click.prompt(f"{indent} {spec.get('prompt', spec['key'])}",
|
|
239
|
+
default=spec.get("default", ""))
|
|
240
|
+
settings[spec["key"]] = val
|
|
241
|
+
enabled[key] = settings
|
|
242
|
+
return enabled
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _prompt_nuget_list(label):
|
|
246
|
+
"""Collect a list of {name, version} refs (version optional -> latest)."""
|
|
247
|
+
refs = []
|
|
248
|
+
while click.confirm(f" Add a{label}?", default=False):
|
|
249
|
+
name = click.prompt(" Package name")
|
|
250
|
+
version = _prompt_optional(" Version (blank = latest)")
|
|
251
|
+
ref = {"name": name}
|
|
252
|
+
if version:
|
|
253
|
+
ref["version"] = version
|
|
254
|
+
refs.append(ref)
|
|
255
|
+
return refs
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@cli.command()
|
|
259
|
+
@click.option("--output", "-o", default="nd-config.yaml", help="Config file to write.")
|
|
260
|
+
def init(output):
|
|
261
|
+
"""Create a config file interactively (covers the full schema)."""
|
|
262
|
+
config_path = Path(output)
|
|
263
|
+
click.secho("ND-SDK Project Generator\n", fg="cyan", bold=True)
|
|
264
|
+
|
|
265
|
+
# ---- project ----
|
|
266
|
+
click.secho("Project", fg="yellow", bold=True)
|
|
267
|
+
name = click.prompt("Solution name", default="ND.SampleSuite")
|
|
268
|
+
namespace_root = click.prompt("Root namespace (blank = service name)", default="ND")
|
|
269
|
+
|
|
270
|
+
variants = available_variants()
|
|
271
|
+
types = sorted({t for t, _ in variants})
|
|
272
|
+
ptype = click.prompt("Project type", type=click.Choice(types), default="microservice")
|
|
273
|
+
styles = sorted({s for t, s in variants if t == ptype})
|
|
274
|
+
architecture = click.prompt("Architecture style", type=click.Choice(styles), default=styles[0])
|
|
275
|
+
dotnet = click.prompt("Target framework", default="net8.0")
|
|
276
|
+
version_strategy = click.prompt(
|
|
277
|
+
"Default NuGet version when unpinned",
|
|
278
|
+
type=click.Choice(["floating", "omit"]),
|
|
279
|
+
default="floating",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
project = {
|
|
283
|
+
"name": name,
|
|
284
|
+
"namespace_root": namespace_root,
|
|
285
|
+
"type": ptype,
|
|
286
|
+
"architecture": architecture,
|
|
287
|
+
"dotnet_version": dotnet,
|
|
288
|
+
"nuget_version_strategy": version_strategy,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# ---- defaults: packages + extra nuget ----
|
|
292
|
+
registry = PackageRegistry()
|
|
293
|
+
click.secho("\nPackages (solution defaults)", fg="yellow", bold=True)
|
|
294
|
+
default_packages = _prompt_packages(registry)
|
|
295
|
+
|
|
296
|
+
click.secho("\nExtra solution-wide NuGet packages", fg="yellow", bold=True)
|
|
297
|
+
default_nuget = _prompt_nuget_list(" package")
|
|
298
|
+
|
|
299
|
+
# ---- package version pins ----
|
|
300
|
+
click.secho("\nPin specific package versions? (otherwise the strategy above applies)",
|
|
301
|
+
fg="yellow", bold=True)
|
|
302
|
+
package_versions = {}
|
|
303
|
+
while click.confirm(" Pin a package version?", default=False):
|
|
304
|
+
pname = click.prompt(" NuGet package name (e.g. ND.Observability.Framework)")
|
|
305
|
+
pver = click.prompt(" Version")
|
|
306
|
+
package_versions[pname] = pver
|
|
307
|
+
|
|
308
|
+
# ---- services ----
|
|
309
|
+
services = []
|
|
310
|
+
add_more = True
|
|
311
|
+
while add_more:
|
|
312
|
+
click.secho(f"\nService #{len(services) + 1}", fg="yellow", bold=True)
|
|
313
|
+
svc_name = click.prompt(" Service name", default="SampleService")
|
|
314
|
+
port = click.prompt(" Local port", default=5000, type=int)
|
|
315
|
+
|
|
316
|
+
svc = {"name": svc_name, "port": port}
|
|
317
|
+
|
|
318
|
+
if click.confirm(" Override packages for this service?", default=False):
|
|
319
|
+
svc["packages"] = _prompt_packages(registry, indent=" ")
|
|
320
|
+
|
|
321
|
+
# ---- integrations (all provider types use the same config shape) ----
|
|
322
|
+
integrations = []
|
|
323
|
+
if click.confirm(" Add a database integration?", default=False):
|
|
324
|
+
db_type = click.prompt(" Database type",
|
|
325
|
+
type=click.Choice(["non_relational", "relational"]),
|
|
326
|
+
default="non_relational")
|
|
327
|
+
store = click.prompt(" Store / engine (e.g. cassandra, dynamodb, postgres, sqlserver)",
|
|
328
|
+
default="cassandra" if db_type == "non_relational" else "sqlserver")
|
|
329
|
+
integration = {
|
|
330
|
+
"name": click.prompt(" Integration name", default="database"),
|
|
331
|
+
"kind": "database",
|
|
332
|
+
"provider": store,
|
|
333
|
+
"settings": {"database_type": db_type},
|
|
334
|
+
}
|
|
335
|
+
conn_name = click.prompt(" Connection string name", default="DefaultConnection" if db_type == "relational" else "CassandraConnection")
|
|
336
|
+
if conn_name:
|
|
337
|
+
integration["settings"]["connection_string_name"] = conn_name
|
|
338
|
+
integrations.append(integration)
|
|
339
|
+
|
|
340
|
+
click.secho(" Models / DTOs", fg="yellow")
|
|
341
|
+
models = _ask_loop("model", _build_model, first_default=True)
|
|
342
|
+
model_names = [m["name"] for m in models]
|
|
343
|
+
|
|
344
|
+
click.secho(" Entities (input objects for stored-procedure / provider calls)", fg="yellow")
|
|
345
|
+
entities = _ask_loop("entity", _build_entity, first_default=False)
|
|
346
|
+
entity_names = [e["name"] for e in entities]
|
|
347
|
+
|
|
348
|
+
click.secho(" Endpoints", fg="yellow")
|
|
349
|
+
endpoints = _ask_loop("endpoint", lambda: _build_endpoint(model_names), first_default=True)
|
|
350
|
+
|
|
351
|
+
click.secho(" Business objects (BO)", fg="yellow")
|
|
352
|
+
bos = _ask_loop("BO", _build_bo, first_default=False)
|
|
353
|
+
|
|
354
|
+
click.secho(" Providers", fg="yellow")
|
|
355
|
+
providers = _ask_loop("provider", lambda: _build_provider(entity_names), first_default=False)
|
|
356
|
+
|
|
357
|
+
svc["endpoints"] = endpoints
|
|
358
|
+
svc["models"] = models
|
|
359
|
+
if integrations:
|
|
360
|
+
svc["integrations"] = integrations
|
|
361
|
+
if entities:
|
|
362
|
+
svc["entities"] = entities
|
|
363
|
+
if bos:
|
|
364
|
+
svc["bos"] = bos
|
|
365
|
+
if providers:
|
|
366
|
+
svc["providers"] = providers
|
|
367
|
+
services.append(svc)
|
|
368
|
+
|
|
369
|
+
add_more = click.confirm("\nAdd another service?", default=False)
|
|
370
|
+
|
|
371
|
+
# ---- assemble ----
|
|
372
|
+
config = {"project": project}
|
|
373
|
+
defaults = {}
|
|
374
|
+
if default_packages:
|
|
375
|
+
defaults["packages"] = default_packages
|
|
376
|
+
if default_nuget:
|
|
377
|
+
defaults["nuget"] = default_nuget
|
|
378
|
+
if defaults:
|
|
379
|
+
config["defaults"] = defaults
|
|
380
|
+
if package_versions:
|
|
381
|
+
config["package_versions"] = package_versions
|
|
382
|
+
config["services"] = services
|
|
383
|
+
|
|
384
|
+
if config_path.exists() and not click.confirm(f"{output} exists. Overwrite?"):
|
|
385
|
+
return
|
|
386
|
+
config_path.write_text(yaml.dump(config, sort_keys=False, indent=2))
|
|
387
|
+
click.secho(f"\n✓ Created {output}", fg="green", bold=True)
|
|
388
|
+
|
|
389
|
+
ok, errors = ConfigValidator().validate(config)
|
|
390
|
+
if ok:
|
|
391
|
+
click.echo(f" Validated OK. Next: ndsdk generate -c {output}")
|
|
392
|
+
else:
|
|
393
|
+
click.secho(" Note: config has validation issues:", fg="yellow")
|
|
394
|
+
for e in errors:
|
|
395
|
+
click.secho(f" • {e}", fg="yellow")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# --------------------------------------------------------------------------- #
|
|
399
|
+
@cli.command()
|
|
400
|
+
@click.option("--config", "-c", type=click.Path(exists=True), required=True)
|
|
401
|
+
@click.option("--output", "-o", type=click.Path(), default=".")
|
|
402
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite write-once (user-owned) files too.")
|
|
403
|
+
@click.option("--service", "-s", "services", multiple=True,
|
|
404
|
+
help="Only (re)generate these services. Repeatable.")
|
|
405
|
+
@click.option("--layer", "-l", "layers", multiple=True,
|
|
406
|
+
help="Only (re)generate these layers (e.g. model, provider, service). Repeatable.")
|
|
407
|
+
def generate(config, output, force, services, layers):
|
|
408
|
+
"""Generate a C# solution from a config file.
|
|
409
|
+
|
|
410
|
+
Selective regeneration: restrict to one or more services and/or layers, e.g.
|
|
411
|
+
`ndsdk generate -c cfg.yaml -s OrderService -l model` rebuilds only that
|
|
412
|
+
service's models. Add --force to re-scaffold write-once files (service impl,
|
|
413
|
+
provider scaffolds). A filtered run never rewrites the .sln or config copy.
|
|
414
|
+
"""
|
|
415
|
+
config_data = yaml.safe_load(Path(config).read_text())
|
|
416
|
+
|
|
417
|
+
ok, errors = ConfigValidator().validate(config_data)
|
|
418
|
+
if not ok:
|
|
419
|
+
click.secho("Configuration invalid:", fg="red", bold=True)
|
|
420
|
+
for e in errors:
|
|
421
|
+
click.secho(f" • {e}", fg="red")
|
|
422
|
+
raise SystemExit(1)
|
|
423
|
+
|
|
424
|
+
known_layers = set(available_layers())
|
|
425
|
+
bad = [l for l in layers if l not in known_layers]
|
|
426
|
+
if bad:
|
|
427
|
+
click.secho(
|
|
428
|
+
f"Unknown layer(s): {', '.join(bad)}. Available: {', '.join(sorted(known_layers))}",
|
|
429
|
+
fg="red", bold=True,
|
|
430
|
+
)
|
|
431
|
+
raise SystemExit(1)
|
|
432
|
+
|
|
433
|
+
flt = GenerationFilter(services=set(services), layers=set(layers), force=force)
|
|
434
|
+
|
|
435
|
+
generator = get_generator(config_data, output)
|
|
436
|
+
if services:
|
|
437
|
+
known_svcs = {s["name"] for s in config_data.get("services", [])}
|
|
438
|
+
missing = [s for s in services if s not in known_svcs]
|
|
439
|
+
if missing:
|
|
440
|
+
click.secho(f"Unknown service(s): {', '.join(missing)}", fg="red", bold=True)
|
|
441
|
+
raise SystemExit(1)
|
|
442
|
+
|
|
443
|
+
if not flt.active and generator.solution_dir.exists() and not force:
|
|
444
|
+
click.confirm(
|
|
445
|
+
f"'{generator.solution_name}' exists. Overwrite?", abort=True
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if flt.active:
|
|
449
|
+
scope = []
|
|
450
|
+
if services:
|
|
451
|
+
scope.append("services=" + ",".join(services))
|
|
452
|
+
if layers:
|
|
453
|
+
scope.append("layers=" + ",".join(layers))
|
|
454
|
+
click.echo(f"Selective regenerate ({'; '.join(scope)})"
|
|
455
|
+
f"{' [--force]' if force else ''}...")
|
|
456
|
+
else:
|
|
457
|
+
click.echo(f"Generating {generator.ptype}/{generator.style} "
|
|
458
|
+
f"solution '{generator.solution_name}'...")
|
|
459
|
+
|
|
460
|
+
files = generator.generate(flt)
|
|
461
|
+
click.secho(f"\n✓ Wrote {len(files)} files to {generator.solution_dir}", fg="green", bold=True)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --------------------------------------------------------------------------- #
|
|
465
|
+
@cli.command()
|
|
466
|
+
@click.argument("config", type=click.Path(exists=True))
|
|
467
|
+
def validate(config):
|
|
468
|
+
"""Validate a config file."""
|
|
469
|
+
config_data = yaml.safe_load(Path(config).read_text())
|
|
470
|
+
ok, errors = ConfigValidator().validate(config_data)
|
|
471
|
+
if ok:
|
|
472
|
+
click.secho("✓ Configuration is valid", fg="green", bold=True)
|
|
473
|
+
else:
|
|
474
|
+
click.secho("✗ Configuration has errors:", fg="red", bold=True)
|
|
475
|
+
for e in errors:
|
|
476
|
+
click.secho(f" • {e}", fg="red")
|
|
477
|
+
raise SystemExit(1)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------------------- #
|
|
481
|
+
@cli.command()
|
|
482
|
+
@click.argument("config", type=click.Path(exists=True))
|
|
483
|
+
def preview(config):
|
|
484
|
+
"""Show what would be generated without writing files."""
|
|
485
|
+
config_data = normalize_config(yaml.safe_load(Path(config).read_text()))
|
|
486
|
+
project = config_data.get("project", {})
|
|
487
|
+
click.secho(f"Solution: {project.get('name')}", fg="cyan", bold=True)
|
|
488
|
+
click.echo(f"Architecture: {project.get('type', 'microservice')}/{project.get('architecture', 'layered')}")
|
|
489
|
+
click.echo(f"Framework: {project.get('dotnet_version', 'net8.0')}")
|
|
490
|
+
for svc in config_data.get("services", []):
|
|
491
|
+
click.secho(f"\n {svc['name']}/", fg="yellow", bold=True)
|
|
492
|
+
click.echo(f" Controllers/{svc['name']}Controller.cs (locked)")
|
|
493
|
+
click.echo(f" Service/I{svc['name']}Service.cs (generated)")
|
|
494
|
+
click.echo(f" Service/{svc['name']}Service.cs (stub)")
|
|
495
|
+
for ep in svc.get("endpoints", []):
|
|
496
|
+
click.echo(f" {ep.get('http_method', 'GET'):6} /{ep.get('route', ep['name'])}")
|
|
497
|
+
for d in svc.get("dtos", []):
|
|
498
|
+
click.echo(f" Dtos/{d['name']}.cs")
|
|
499
|
+
for m in svc.get("models", []):
|
|
500
|
+
click.echo(f" Models/{m['name']}.cs")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# --------------------------------------------------------------------------- #
|
|
504
|
+
@cli.command()
|
|
505
|
+
def packages():
|
|
506
|
+
"""List packages the CLI can wire in."""
|
|
507
|
+
registry = PackageRegistry()
|
|
508
|
+
click.secho("Available packages:\n", fg="cyan", bold=True)
|
|
509
|
+
for key in registry.available():
|
|
510
|
+
pkg = registry.get(key)
|
|
511
|
+
click.secho(f" {pkg.key}", fg="yellow", bold=True)
|
|
512
|
+
click.echo(f" {pkg.description}")
|
|
513
|
+
if pkg.nuget:
|
|
514
|
+
refs = ", ".join(f"{r.name} {r.version}".strip() for r in pkg.nuget)
|
|
515
|
+
click.echo(f" NuGet: {refs}")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@cli.command()
|
|
519
|
+
def architectures():
|
|
520
|
+
"""List supported architectures (type / style)."""
|
|
521
|
+
click.secho("Available architectures (type/style):", fg="cyan", bold=True)
|
|
522
|
+
for t_, s_ in available_variants():
|
|
523
|
+
click.echo(f" • {t_}/{s_}")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@cli.command()
|
|
527
|
+
def layers():
|
|
528
|
+
"""List layers that can be selectively (re)generated via `generate --layer`."""
|
|
529
|
+
click.secho("Selectively regenerable layers:", fg="cyan", bold=True)
|
|
530
|
+
for layer in available_layers():
|
|
531
|
+
click.echo(f" • {layer}")
|
|
532
|
+
click.echo("\n e.g. ndsdk generate -c nd-config.yaml -s OrderService -l model -l provider")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
cli()
|
ndsdk/naming.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Naming helpers shared by templates and generators.
|
|
2
|
+
|
|
3
|
+
C# leans on PascalCase for types/namespaces and camelCase for locals/params,
|
|
4
|
+
so the Jinja filters here mirror that. Kept dependency-free on purpose.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _words(text: str) -> list[str]:
|
|
11
|
+
"""Split an arbitrary identifier into lowercase words."""
|
|
12
|
+
if not text:
|
|
13
|
+
return []
|
|
14
|
+
# insert breaks: aB -> a B, ABc -> A Bc
|
|
15
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
|
|
16
|
+
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
|
|
17
|
+
text = text.replace("-", " ").replace("_", " ").replace(".", " ")
|
|
18
|
+
return [w for w in text.split() if w]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pascal_case(text: str) -> str:
|
|
22
|
+
"""`perform-textract` -> `PerformTextract`."""
|
|
23
|
+
return "".join(w[:1].upper() + w[1:] for w in _words(text))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def camel_case(text: str) -> str:
|
|
27
|
+
"""`PerformTextract` -> `performTextract`."""
|
|
28
|
+
p = pascal_case(text)
|
|
29
|
+
return p[:1].lower() + p[1:] if p else p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def snake_case(text: str) -> str:
|
|
33
|
+
return "_".join(w.lower() for w in _words(text))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def kebab_case(text: str) -> str:
|
|
37
|
+
return "-".join(w.lower() for w in _words(text))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def namespace(*parts: str) -> str:
|
|
41
|
+
"""Join namespace segments, each PascalCased, skipping empties.
|
|
42
|
+
|
|
43
|
+
`namespace("ND", "perform-textract")` -> `ND.PerformTextract`
|
|
44
|
+
"""
|
|
45
|
+
segs = []
|
|
46
|
+
for part in parts:
|
|
47
|
+
if not part:
|
|
48
|
+
continue
|
|
49
|
+
# a part may itself already be dotted (e.g. "ND.Observability")
|
|
50
|
+
for seg in str(part).split("."):
|
|
51
|
+
if seg:
|
|
52
|
+
segs.append(pascal_case(seg))
|
|
53
|
+
return ".".join(segs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Map loose YAML type names onto C# types so the same config can describe
|
|
57
|
+
# a model field once and be rendered correctly.
|
|
58
|
+
_CSHARP_TYPE_MAP = {
|
|
59
|
+
"string": "string",
|
|
60
|
+
"str": "string",
|
|
61
|
+
"int": "int",
|
|
62
|
+
"integer": "int",
|
|
63
|
+
"long": "long",
|
|
64
|
+
"float": "float",
|
|
65
|
+
"double": "double",
|
|
66
|
+
"decimal": "decimal",
|
|
67
|
+
"bool": "bool",
|
|
68
|
+
"boolean": "bool",
|
|
69
|
+
"datetime": "DateTime",
|
|
70
|
+
"date": "DateTime",
|
|
71
|
+
"guid": "Guid",
|
|
72
|
+
"object": "object",
|
|
73
|
+
"dynamic": "dynamic",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def csharp_type(type_name: str) -> str:
|
|
78
|
+
"""Resolve a config type string into a C# type.
|
|
79
|
+
|
|
80
|
+
Handles `list<T>` / `List<T>` and `T[]`, leaving unknown (custom) types
|
|
81
|
+
such as `ADCItem` untouched so they can reference generated models.
|
|
82
|
+
"""
|
|
83
|
+
if not type_name:
|
|
84
|
+
return "object"
|
|
85
|
+
raw = str(type_name).strip()
|
|
86
|
+
|
|
87
|
+
# array suffix: Foo[]
|
|
88
|
+
if raw.endswith("[]"):
|
|
89
|
+
return f"{csharp_type(raw[:-2])}[]"
|
|
90
|
+
|
|
91
|
+
# generic list/collection: list<Foo>, List<Foo>, ienumerable<Foo>
|
|
92
|
+
m = re.match(r"^(list|ilist|ienumerable|collection)<(.+)>$", raw, re.IGNORECASE)
|
|
93
|
+
if m:
|
|
94
|
+
inner = csharp_type(m.group(2))
|
|
95
|
+
return f"List<{inner}>"
|
|
96
|
+
|
|
97
|
+
# dictionary<K,V>
|
|
98
|
+
m = re.match(r"^(dict|dictionary)<(.+),(.+)>$", raw, re.IGNORECASE)
|
|
99
|
+
if m:
|
|
100
|
+
return f"Dictionary<{csharp_type(m.group(2))}, {csharp_type(m.group(3))}>"
|
|
101
|
+
|
|
102
|
+
return _CSHARP_TYPE_MAP.get(raw.lower(), raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register_filters(env) -> None:
|
|
106
|
+
"""Attach all naming helpers as Jinja filters on an Environment."""
|
|
107
|
+
env.filters["pascal_case"] = pascal_case
|
|
108
|
+
env.filters["camel_case"] = camel_case
|
|
109
|
+
env.filters["snake_case"] = snake_case
|
|
110
|
+
env.filters["kebab_case"] = kebab_case
|
|
111
|
+
env.filters["csharp_type"] = csharp_type
|
|
112
|
+
env.globals["ns"] = namespace
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
builder.Services.AddClientProvider();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ClientProvider": {
|
|
3
|
+
"TimeoutInSeconds": {{ settings.timeout_in_seconds }},
|
|
4
|
+
"RetryPolicy": {
|
|
5
|
+
"MaxRetryAttempts": {{ settings.max_retry_attempts }},
|
|
6
|
+
"RetryDelayInSeconds": {{ settings.retry_delay_in_seconds }},
|
|
7
|
+
"RetryableStatusCodes": {{ settings.retryable_status_codes | tojson }}
|
|
8
|
+
},
|
|
9
|
+
"CircuitBreaker": {
|
|
10
|
+
"FailureThreshold": {{ settings.failure_threshold }},
|
|
11
|
+
"DurationOfBreakInSeconds": {{ settings.duration_of_break_in_seconds }}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
key: client_provider
|
|
2
|
+
display_name: Client Provider
|
|
3
|
+
description: ND.FW.ClientProvider resilient HTTP client (timeout, retry, circuit breaker).
|
|
4
|
+
nuget:
|
|
5
|
+
- { name: ND.FW.ClientProvider, version: 0.0.3 }
|
|
6
|
+
defaults:
|
|
7
|
+
enabled: true
|
|
8
|
+
timeout_in_seconds: 30
|
|
9
|
+
max_retry_attempts: 3
|
|
10
|
+
retry_delay_in_seconds: 2
|
|
11
|
+
retryable_status_codes: [408, 429, 500, 502, 503, 504]
|
|
12
|
+
failure_threshold: 5
|
|
13
|
+
duration_of_break_in_seconds: 30
|