dataverse-types-gen 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +63 -0
- package/dist/generator.d.ts +11 -0
- package/dist/generator.js +341 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edward Tombre
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# dataverse-types-gen
|
|
2
|
+
|
|
3
|
+
Generate TypeScript types and optionset enums from a Microsoft Dataverse / Dynamics 365 environment's `$metadata` endpoint.
|
|
4
|
+
|
|
5
|
+
For each requested entity the CLI writes:
|
|
6
|
+
|
|
7
|
+
- An `<entity>.ts` file with an `interface` for the entity, an `<Entity>Bind` interface for `@odata.bind` writes, and imports for related entities
|
|
8
|
+
- A shared `optionsets.ts` file with `enum`s for every picklist/state/status/multiselect option set discovered across the requested entities
|
|
9
|
+
- An `index.ts` barrel re-exporting all generated types
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install --save-dev dataverse-types-gen
|
|
15
|
+
# or
|
|
16
|
+
pnpm add -D dataverse-types-gen
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### 1. Create a config
|
|
22
|
+
|
|
23
|
+
`dataverse-types.config.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"entities": ["account", "contact", "msdyn_workorder"],
|
|
28
|
+
"outputDir": "src/dataverse-gen"
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Set environment variables
|
|
33
|
+
|
|
34
|
+
The generator authenticates with Microsoft Entra ID using the client-credentials flow. Provide these via your shell, a `.env` file, or your runner's secret store:
|
|
35
|
+
|
|
36
|
+
| Variable | Description |
|
|
37
|
+
| ------------------------ | ------------------------------------------------------------------------ |
|
|
38
|
+
| `SERVER_URL` | Dataverse environment URL, e.g. `https://contoso.crm.dynamics.com` |
|
|
39
|
+
| `AUTHORITY_URL` | OAuth authority, e.g. `https://login.microsoftonline.com/<tenant-id>` |
|
|
40
|
+
| `DYNAMICS_CLIENT_ID` | Azure AD application (client) ID |
|
|
41
|
+
| `DYNAMICS_CLIENT_SECRET` | Azure AD application client secret |
|
|
42
|
+
|
|
43
|
+
The app registration must be granted access to the Dataverse environment as an application user with read permission on entity metadata.
|
|
44
|
+
|
|
45
|
+
### 3. Run it
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
npx dataverse-types-gen
|
|
49
|
+
# or with an explicit config path
|
|
50
|
+
npx dataverse-types-gen --config ./config/dataverse.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A common pattern is to add it as a script in your `package.json`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"scripts": {
|
|
58
|
+
"gen:types": "dataverse-types-gen"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## What gets generated
|
|
64
|
+
|
|
65
|
+
For `entities: ["account"]` you get:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
src/dataverse-gen/
|
|
69
|
+
account.ts # export interface Account, export interface AccountBind
|
|
70
|
+
optionsets.ts # export enum AccountStatecode, ...
|
|
71
|
+
index.ts # re-exports
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
1. Acquires a bearer token via MSAL using client credentials.
|
|
77
|
+
2. Fetches the OData `$metadata` document and parses the EDM schema.
|
|
78
|
+
3. For each requested entity, fetches `EntityDefinitions(...)/Attributes/Microsoft.Dynamics.CRM.<PicklistSubtype>` to enumerate option sets.
|
|
79
|
+
4. Renders one `.ts` file per entity plus a shared `optionsets.ts`.
|
|
80
|
+
|
|
81
|
+
Inherited properties are flattened from `@BaseType` chains. Navigation properties to entities in your requested set are typed; those outside the set fall back to `unknown` (use `$expand` carefully).
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { generate, loadConfig } from "./generator.js";
|
|
4
|
+
const printUsage = () => {
|
|
5
|
+
console.log(`Usage: dataverse-types-gen [--config <path>]
|
|
6
|
+
|
|
7
|
+
Generates TypeScript types and optionset enums from a Microsoft Dataverse
|
|
8
|
+
environment's $metadata endpoint.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--config <path> Path to config JSON (default: ./dataverse-types.config.json)
|
|
12
|
+
--help, -h Show this help
|
|
13
|
+
|
|
14
|
+
Config JSON shape:
|
|
15
|
+
{
|
|
16
|
+
"entities": ["account", "contact", ...],
|
|
17
|
+
"outputDir": "src/dataverse-gen"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Environment variables (loaded from .env if present):
|
|
21
|
+
SERVER_URL Dataverse environment URL (e.g. https://org.crm.dynamics.com)
|
|
22
|
+
AUTHORITY_URL OAuth authority (e.g. https://login.microsoftonline.com/<tenant-id>)
|
|
23
|
+
DYNAMICS_CLIENT_ID Azure AD app client ID
|
|
24
|
+
DYNAMICS_CLIENT_SECRET Azure AD app client secret
|
|
25
|
+
`);
|
|
26
|
+
};
|
|
27
|
+
const parseArgs = (argv) => {
|
|
28
|
+
let configPath = "dataverse-types.config.json";
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const a = argv[i];
|
|
31
|
+
if (a === "--help" || a === "-h") {
|
|
32
|
+
printUsage();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
else if (a === "--config") {
|
|
36
|
+
const next = argv[i + 1];
|
|
37
|
+
if (!next) {
|
|
38
|
+
console.error("Error: --config requires a path argument");
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
configPath = next;
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
console.error(`Error: unknown argument '${a}'`);
|
|
46
|
+
printUsage();
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { configPath };
|
|
51
|
+
};
|
|
52
|
+
const main = async () => {
|
|
53
|
+
const { configPath } = parseArgs(process.argv.slice(2));
|
|
54
|
+
const config = loadConfig(configPath);
|
|
55
|
+
await generate({
|
|
56
|
+
config,
|
|
57
|
+
regenCommand: `npx dataverse-types-gen --config ${configPath}`
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
main().catch((err) => {
|
|
61
|
+
console.error(err instanceof Error ? err.message : err);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface GenConfig {
|
|
2
|
+
entities: string[];
|
|
3
|
+
outputDir: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const loadConfig: (configPath: string) => GenConfig;
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
config: GenConfig;
|
|
8
|
+
regenCommand?: string;
|
|
9
|
+
log?: (msg: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare const generate: ({ config, regenCommand, log }: RunOptions) => Promise<void>;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as MSAL from "@azure/msal-node";
|
|
4
|
+
import { XMLParser } from "fast-xml-parser";
|
|
5
|
+
const arr = (v) => v === undefined ? [] : Array.isArray(v) ? v : [v];
|
|
6
|
+
const requireEnv = (name) => {
|
|
7
|
+
const v = process.env[name];
|
|
8
|
+
if (!v)
|
|
9
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
10
|
+
return v;
|
|
11
|
+
};
|
|
12
|
+
const acquireToken = async () => {
|
|
13
|
+
const cca = new MSAL.ConfidentialClientApplication({
|
|
14
|
+
auth: {
|
|
15
|
+
clientId: requireEnv("DYNAMICS_CLIENT_ID"),
|
|
16
|
+
clientSecret: requireEnv("DYNAMICS_CLIENT_SECRET"),
|
|
17
|
+
knownAuthorities: ["login.microsoftonline.com"]
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
const result = await cca.acquireTokenByClientCredential({
|
|
21
|
+
authority: requireEnv("AUTHORITY_URL"),
|
|
22
|
+
scopes: [`${requireEnv("SERVER_URL")}/.default`]
|
|
23
|
+
});
|
|
24
|
+
if (!result?.accessToken) {
|
|
25
|
+
throw new Error("Failed to acquire access token");
|
|
26
|
+
}
|
|
27
|
+
return result.accessToken;
|
|
28
|
+
};
|
|
29
|
+
const fetchMetadata = async (token) => {
|
|
30
|
+
const url = `${requireEnv("SERVER_URL")}/api/data/v9.2/$metadata`;
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${token}`,
|
|
34
|
+
Accept: "application/xml"
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(`Failed to fetch $metadata: ${res.status} ${res.statusText}`);
|
|
39
|
+
}
|
|
40
|
+
return res.text();
|
|
41
|
+
};
|
|
42
|
+
const PICKLIST_SUBTYPES = [
|
|
43
|
+
"PicklistAttributeMetadata",
|
|
44
|
+
"StatusAttributeMetadata",
|
|
45
|
+
"StateAttributeMetadata",
|
|
46
|
+
"MultiSelectPicklistAttributeMetadata"
|
|
47
|
+
];
|
|
48
|
+
const fetchPicklistsForEntity = async (token, entityLogicalName) => {
|
|
49
|
+
const base = `${requireEnv("SERVER_URL")}/api/data/v9.2`;
|
|
50
|
+
const headers = {
|
|
51
|
+
Authorization: `Bearer ${token}`,
|
|
52
|
+
Accept: "application/json",
|
|
53
|
+
"OData-Version": "4.0",
|
|
54
|
+
"OData-MaxVersion": "4.0"
|
|
55
|
+
};
|
|
56
|
+
const all = [];
|
|
57
|
+
for (const subtype of PICKLIST_SUBTYPES) {
|
|
58
|
+
const url = `${base}/EntityDefinitions(LogicalName='${entityLogicalName}')` +
|
|
59
|
+
`/Attributes/Microsoft.Dynamics.CRM.${subtype}` +
|
|
60
|
+
`?$select=LogicalName&$expand=OptionSet($select=Name,IsGlobal,Options)`;
|
|
61
|
+
const res = await fetch(url, { headers });
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
throw new Error(`Failed to fetch ${subtype} for ${entityLogicalName}: ${res.status} ${res.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
const body = (await res.json());
|
|
66
|
+
all.push(...body.value.filter((a) => a.OptionSet));
|
|
67
|
+
}
|
|
68
|
+
return all;
|
|
69
|
+
};
|
|
70
|
+
const englishLabel = (label) => {
|
|
71
|
+
const en = label.LocalizedLabels?.find((l) => l.LanguageCode === 1033);
|
|
72
|
+
if (en)
|
|
73
|
+
return en.Label;
|
|
74
|
+
if (label.UserLocalizedLabel)
|
|
75
|
+
return label.UserLocalizedLabel.Label;
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
const sanitiseEnumMember = (raw, value) => {
|
|
79
|
+
let s = raw
|
|
80
|
+
.normalize("NFD")
|
|
81
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
82
|
+
.replace(/[^A-Za-z0-9_]+/g, "_")
|
|
83
|
+
.replace(/^_+|_+$/g, "");
|
|
84
|
+
if (s.length === 0)
|
|
85
|
+
s = `Option_${value}`;
|
|
86
|
+
if (/^[0-9]/.test(s))
|
|
87
|
+
s = `_${s}`;
|
|
88
|
+
return s;
|
|
89
|
+
};
|
|
90
|
+
const enumTypeName = (optionSetName) => optionSetName
|
|
91
|
+
.split(/[_\s]+/)
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
94
|
+
.join("");
|
|
95
|
+
const HEADER = (cmd) => [
|
|
96
|
+
`// AUTO-GENERATED by dataverse-types-gen. Do not edit by hand.`,
|
|
97
|
+
`// Regenerate with: ${cmd}`,
|
|
98
|
+
""
|
|
99
|
+
];
|
|
100
|
+
const renderOptionSets = (optionSets, regenCommand) => {
|
|
101
|
+
const lines = [...HEADER(regenCommand)];
|
|
102
|
+
for (const [, os] of [...optionSets.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
103
|
+
const tname = enumTypeName(os.Name);
|
|
104
|
+
lines.push(`export enum ${tname} {`);
|
|
105
|
+
const seenMembers = new Set();
|
|
106
|
+
for (const opt of os.Options) {
|
|
107
|
+
const label = englishLabel(opt.Label);
|
|
108
|
+
const baseMember = label ? sanitiseEnumMember(label, opt.Value) : `Option_${opt.Value}`;
|
|
109
|
+
let member = baseMember;
|
|
110
|
+
let i = 2;
|
|
111
|
+
while (seenMembers.has(member)) {
|
|
112
|
+
member = `${baseMember}_${i++}`;
|
|
113
|
+
}
|
|
114
|
+
seenMembers.add(member);
|
|
115
|
+
lines.push(` ${member} = ${opt.Value},`);
|
|
116
|
+
}
|
|
117
|
+
lines.push("}");
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
};
|
|
122
|
+
const edmToTs = (edmType, nullable) => {
|
|
123
|
+
const collection = edmType.startsWith("Collection(") && edmType.endsWith(")");
|
|
124
|
+
const inner = collection ? edmType.slice(11, -1) : edmType;
|
|
125
|
+
let ts;
|
|
126
|
+
switch (inner) {
|
|
127
|
+
case "Edm.String":
|
|
128
|
+
case "Edm.Guid":
|
|
129
|
+
case "Edm.DateTimeOffset":
|
|
130
|
+
case "Edm.Date":
|
|
131
|
+
case "Edm.Binary":
|
|
132
|
+
ts = "string";
|
|
133
|
+
break;
|
|
134
|
+
case "Edm.Int32":
|
|
135
|
+
case "Edm.Int64":
|
|
136
|
+
case "Edm.Decimal":
|
|
137
|
+
case "Edm.Double":
|
|
138
|
+
case "Edm.Single":
|
|
139
|
+
ts = "number";
|
|
140
|
+
break;
|
|
141
|
+
case "Edm.Boolean":
|
|
142
|
+
ts = "boolean";
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
ts = "unknown";
|
|
146
|
+
}
|
|
147
|
+
if (collection)
|
|
148
|
+
ts = `${ts}[]`;
|
|
149
|
+
return nullable ? `${ts} | null` : ts;
|
|
150
|
+
};
|
|
151
|
+
const isPropName = (s) => /^[A-Za-z_][\w]*$/.test(s);
|
|
152
|
+
const quoteKey = (s) => (isPropName(s) ? s : JSON.stringify(s));
|
|
153
|
+
const navTargetEntityName = (fullType, prefixes) => {
|
|
154
|
+
const collection = fullType.startsWith("Collection(") && fullType.endsWith(")");
|
|
155
|
+
const inner = collection ? fullType.slice(11, -1) : fullType;
|
|
156
|
+
let logicalName = inner;
|
|
157
|
+
for (const ns of prefixes) {
|
|
158
|
+
const p = `${ns}.`;
|
|
159
|
+
if (inner.startsWith(p)) {
|
|
160
|
+
logicalName = inner.slice(p.length);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { logicalName, collection };
|
|
165
|
+
};
|
|
166
|
+
const tsTypeName = (logicalName) => logicalName.charAt(0).toUpperCase() + logicalName.slice(1);
|
|
167
|
+
const renderEntity = (entity, nsPrefixes, requestedSet, attrEnums, regenCommand) => {
|
|
168
|
+
const name = entity["@_Name"];
|
|
169
|
+
const typeName = tsTypeName(name);
|
|
170
|
+
const props = arr(entity.Property);
|
|
171
|
+
const navs = arr(entity.NavigationProperty);
|
|
172
|
+
const imports = new Set();
|
|
173
|
+
const enumImports = new Set();
|
|
174
|
+
const lines = [...HEADER(regenCommand)];
|
|
175
|
+
const navTargets = navs.map((n) => navTargetEntityName(n["@_Type"], nsPrefixes));
|
|
176
|
+
for (const t of navTargets) {
|
|
177
|
+
if (requestedSet.has(t.logicalName) && t.logicalName !== name) {
|
|
178
|
+
imports.add(t.logicalName);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const p of props) {
|
|
182
|
+
const enumType = attrEnums.get(p["@_Name"]);
|
|
183
|
+
if (enumType && p["@_Type"] === "Edm.Int32")
|
|
184
|
+
enumImports.add(enumType);
|
|
185
|
+
}
|
|
186
|
+
const importLines = [...imports]
|
|
187
|
+
.sort()
|
|
188
|
+
.map((n) => `import type { ${tsTypeName(n)} } from "./${n}";`);
|
|
189
|
+
if (importLines.length > 0) {
|
|
190
|
+
lines.push(...importLines, "");
|
|
191
|
+
}
|
|
192
|
+
if (enumImports.size > 0) {
|
|
193
|
+
lines.push(`import type { ${[...enumImports].sort().join(", ")} } from "./optionsets";`, "");
|
|
194
|
+
}
|
|
195
|
+
lines.push(`export interface ${typeName} {`);
|
|
196
|
+
for (const p of props) {
|
|
197
|
+
const pname = p["@_Name"];
|
|
198
|
+
const nullable = p["@_Nullable"] !== "false";
|
|
199
|
+
const enumType = attrEnums.get(pname);
|
|
200
|
+
const useEnum = enumType && p["@_Type"] === "Edm.Int32";
|
|
201
|
+
const ts = useEnum
|
|
202
|
+
? nullable
|
|
203
|
+
? `${enumType} | null`
|
|
204
|
+
: enumType
|
|
205
|
+
: edmToTs(p["@_Type"], nullable);
|
|
206
|
+
lines.push(` ${quoteKey(pname)}?: ${ts};`);
|
|
207
|
+
}
|
|
208
|
+
if (navs.length > 0) {
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(" // Navigation properties (use $expand to populate)");
|
|
211
|
+
for (const n of navs) {
|
|
212
|
+
const nname = n["@_Name"];
|
|
213
|
+
const target = navTargetEntityName(n["@_Type"], nsPrefixes);
|
|
214
|
+
const targetTs = requestedSet.has(target.logicalName)
|
|
215
|
+
? tsTypeName(target.logicalName)
|
|
216
|
+
: "unknown";
|
|
217
|
+
const ts = target.collection ? `${targetTs}[]` : targetTs;
|
|
218
|
+
lines.push(` ${quoteKey(nname)}?: ${ts};`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
lines.push("}");
|
|
222
|
+
lines.push("");
|
|
223
|
+
const singleValueLookups = navs.filter((n) => !navTargetEntityName(n["@_Type"], nsPrefixes).collection);
|
|
224
|
+
if (singleValueLookups.length > 0) {
|
|
225
|
+
lines.push(`export interface ${typeName}Bind {`);
|
|
226
|
+
for (const n of singleValueLookups) {
|
|
227
|
+
const target = navTargetEntityName(n["@_Type"], nsPrefixes);
|
|
228
|
+
const bindKey = `${n["@_Name"]}@odata.bind`;
|
|
229
|
+
lines.push(` ${quoteKey(bindKey)}?: string; // /<entityset>(<id>) referencing ${target.logicalName}`);
|
|
230
|
+
}
|
|
231
|
+
lines.push("}");
|
|
232
|
+
lines.push("");
|
|
233
|
+
}
|
|
234
|
+
return { code: lines.join("\n"), imports, enumImports };
|
|
235
|
+
};
|
|
236
|
+
export const loadConfig = (configPath) => {
|
|
237
|
+
const resolved = path.resolve(configPath);
|
|
238
|
+
if (!fs.existsSync(resolved)) {
|
|
239
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
240
|
+
}
|
|
241
|
+
const cfg = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
242
|
+
if (!Array.isArray(cfg.entities) || cfg.entities.length === 0) {
|
|
243
|
+
throw new Error(`Config '${resolved}' must contain a non-empty 'entities' array`);
|
|
244
|
+
}
|
|
245
|
+
if (typeof cfg.outputDir !== "string" || !cfg.outputDir) {
|
|
246
|
+
throw new Error(`Config '${resolved}' must contain a non-empty 'outputDir' string`);
|
|
247
|
+
}
|
|
248
|
+
return { entities: cfg.entities, outputDir: cfg.outputDir };
|
|
249
|
+
};
|
|
250
|
+
export const generate = async ({ config, regenCommand = "npx dataverse-types-gen", log = console.log }) => {
|
|
251
|
+
log(`Fetching $metadata from ${requireEnv("SERVER_URL")}`);
|
|
252
|
+
const token = await acquireToken();
|
|
253
|
+
const xml = await fetchMetadata(token);
|
|
254
|
+
const parser = new XMLParser({
|
|
255
|
+
ignoreAttributes: false,
|
|
256
|
+
attributeNamePrefix: "@_",
|
|
257
|
+
isArray: (name) => ["EntityType", "Property", "NavigationProperty", "EntitySet", "Schema"].includes(name)
|
|
258
|
+
});
|
|
259
|
+
const doc = parser.parse(xml);
|
|
260
|
+
const schemas = arr(doc?.["edmx:Edmx"]?.["edmx:DataServices"]?.Schema);
|
|
261
|
+
const crmSchema = schemas.find((s) => s["@_Namespace"] === "Microsoft.Dynamics.CRM");
|
|
262
|
+
if (!crmSchema) {
|
|
263
|
+
throw new Error("Schema 'Microsoft.Dynamics.CRM' not found in $metadata");
|
|
264
|
+
}
|
|
265
|
+
const allEntities = arr(crmSchema.EntityType);
|
|
266
|
+
const namespace = crmSchema["@_Namespace"];
|
|
267
|
+
const alias = crmSchema["@_Alias"];
|
|
268
|
+
const nsPrefixes = [namespace, ...(alias ? [alias] : [])];
|
|
269
|
+
const byName = new Map();
|
|
270
|
+
for (const e of allEntities)
|
|
271
|
+
byName.set(e["@_Name"], e);
|
|
272
|
+
const requested = new Set(config.entities);
|
|
273
|
+
const missing = config.entities.filter((n) => !byName.has(n));
|
|
274
|
+
if (missing.length > 0) {
|
|
275
|
+
throw new Error(`Entities not found in $metadata: ${missing.join(", ")}`);
|
|
276
|
+
}
|
|
277
|
+
const stripNs = (full) => {
|
|
278
|
+
for (const ns of nsPrefixes) {
|
|
279
|
+
const p = `${ns}.`;
|
|
280
|
+
if (full.startsWith(p))
|
|
281
|
+
return full.slice(p.length);
|
|
282
|
+
}
|
|
283
|
+
return full;
|
|
284
|
+
};
|
|
285
|
+
const flattenInherited = (e) => {
|
|
286
|
+
if (!e["@_BaseType"])
|
|
287
|
+
return e;
|
|
288
|
+
const base = byName.get(stripNs(e["@_BaseType"]));
|
|
289
|
+
if (!base)
|
|
290
|
+
return e;
|
|
291
|
+
const flatBase = flattenInherited(base);
|
|
292
|
+
return {
|
|
293
|
+
...e,
|
|
294
|
+
Property: [...arr(flatBase.Property), ...arr(e.Property)],
|
|
295
|
+
NavigationProperty: [
|
|
296
|
+
...arr(flatBase.NavigationProperty),
|
|
297
|
+
...arr(e.NavigationProperty)
|
|
298
|
+
]
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
log(`Fetching OptionSet metadata for ${config.entities.length} entities...`);
|
|
302
|
+
const picklistsByEntity = new Map();
|
|
303
|
+
await Promise.all(config.entities.map(async (logicalName) => {
|
|
304
|
+
const attrs = await fetchPicklistsForEntity(token, logicalName);
|
|
305
|
+
picklistsByEntity.set(logicalName, attrs);
|
|
306
|
+
}));
|
|
307
|
+
const optionSets = new Map();
|
|
308
|
+
const attrEnumsByEntity = new Map();
|
|
309
|
+
for (const [entityName, attrs] of picklistsByEntity) {
|
|
310
|
+
const m = new Map();
|
|
311
|
+
for (const a of attrs) {
|
|
312
|
+
const tname = enumTypeName(a.OptionSet.Name);
|
|
313
|
+
const existing = optionSets.get(a.OptionSet.Name);
|
|
314
|
+
if (!existing) {
|
|
315
|
+
optionSets.set(a.OptionSet.Name, a.OptionSet);
|
|
316
|
+
}
|
|
317
|
+
m.set(a.LogicalName, tname);
|
|
318
|
+
}
|
|
319
|
+
attrEnumsByEntity.set(entityName, m);
|
|
320
|
+
}
|
|
321
|
+
const outDir = path.resolve(config.outputDir);
|
|
322
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
323
|
+
const optionSetsCode = renderOptionSets(optionSets, regenCommand);
|
|
324
|
+
const optionSetsFile = path.join(outDir, "optionsets.ts");
|
|
325
|
+
fs.writeFileSync(optionSetsFile, optionSetsCode);
|
|
326
|
+
log(`Wrote ${path.relative(process.cwd(), optionSetsFile)}`);
|
|
327
|
+
const indexLines = [...HEADER(regenCommand)];
|
|
328
|
+
for (const logicalName of config.entities) {
|
|
329
|
+
const entity = flattenInherited(byName.get(logicalName));
|
|
330
|
+
const attrEnums = attrEnumsByEntity.get(logicalName) ?? new Map();
|
|
331
|
+
const { code } = renderEntity(entity, nsPrefixes, requested, attrEnums, regenCommand);
|
|
332
|
+
const file = path.join(outDir, `${logicalName}.ts`);
|
|
333
|
+
fs.writeFileSync(file, code);
|
|
334
|
+
log(`Wrote ${path.relative(process.cwd(), file)}`);
|
|
335
|
+
indexLines.push(`export type { ${tsTypeName(logicalName)} } from "./${logicalName}";`);
|
|
336
|
+
}
|
|
337
|
+
indexLines.push(`export * from "./optionsets";`);
|
|
338
|
+
const indexFile = path.join(outDir, "index.ts");
|
|
339
|
+
fs.writeFileSync(indexFile, indexLines.join("\n") + "\n");
|
|
340
|
+
log(`Wrote ${path.relative(process.cwd(), indexFile)}`);
|
|
341
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { generate, loadConfig } from "./generator.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dataverse-types-gen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate TypeScript types and optionset enums from a Microsoft Dataverse / Dynamics 365 environment's $metadata.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Dr-Wade/dynamics-tools.git",
|
|
9
|
+
"directory": "packages/dataverse-types-gen"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Dr-Wade/dynamics-tools/tree/main/packages/dataverse-types-gen#readme",
|
|
12
|
+
"bugs": "https://github.com/Dr-Wade/dynamics-tools/issues",
|
|
13
|
+
"author": "Edward Tombre",
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"dataverse-types-gen": "dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"prepublishOnly": "pnpm build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dataverse",
|
|
30
|
+
"dynamics",
|
|
31
|
+
"dynamics-365",
|
|
32
|
+
"d365",
|
|
33
|
+
"types",
|
|
34
|
+
"typescript",
|
|
35
|
+
"codegen",
|
|
36
|
+
"metadata"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@azure/msal-node": "^3.6.2",
|
|
44
|
+
"dotenv": "^17.2.1",
|
|
45
|
+
"fast-xml-parser": "^5.7.2"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^24.0.10",
|
|
49
|
+
"typescript": "^5.8.3"
|
|
50
|
+
}
|
|
51
|
+
}
|