@sbroenne/dvq 0.1.2 → 0.1.5
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/README.md +39 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +57 -9
- package/dist/lib.d.ts +23 -4
- package/dist/lib.js +92 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,10 @@ export DATAVERSE_URL="https://yourorg.crm.dynamics.com"
|
|
|
45
45
|
|
|
46
46
|
You can also pass `--url` on the CLI instead of setting `DATAVERSE_URL`.
|
|
47
47
|
|
|
48
|
+
By default, `dvq` requests Dataverse formatted-value annotations so OptionSet labels and other display values are present in responses. Use `--no-formatted-values` to disable that default, or `--header` to add custom request headers when debugging environment-specific behavior.
|
|
49
|
+
|
|
50
|
+
Use `--verbose` to emit request tracing to stderr. This is useful when you need to see the exact request URL, pagination flow, and sanitized headers without mixing trace output into the JSON response on stdout.
|
|
51
|
+
|
|
48
52
|
## CLI quick start
|
|
49
53
|
|
|
50
54
|
The query is an OData path relative to `/api/data/v9.2/`.
|
|
@@ -77,6 +81,24 @@ Verify auth and connectivity:
|
|
|
77
81
|
dvq --whoami
|
|
78
82
|
```
|
|
79
83
|
|
|
84
|
+
Disable formatted-value annotations for a leaner payload or to isolate header-related issues:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
dvq --url "https://yourorg.crm.dynamics.com" --no-formatted-values "accounts?$top=5"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Add custom request headers:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
dvq --url "https://yourorg.crm.dynamics.com" -H "ConsistencyLevel: eventual" "accounts?$top=5"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Trace auth, request, and pagination flow to stderr:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
dvq --verbose --url "https://yourorg.crm.dynamics.com" --all "accounts?$select=name&$top=5"
|
|
100
|
+
```
|
|
101
|
+
|
|
80
102
|
Follow `@odata.nextLink` pages automatically:
|
|
81
103
|
|
|
82
104
|
```bash
|
|
@@ -95,6 +117,9 @@ dvq [options] [query]
|
|
|
95
117
|
| `-f, --file` | `<path>` | Read the OData path from a file |
|
|
96
118
|
| `-a, --all` | — | Follow `@odata.nextLink` pages up to the built-in safety cap |
|
|
97
119
|
| `-u, --url` | `<url>` | Use this Dataverse base URL instead of `DATAVERSE_URL` |
|
|
120
|
+
| `-v, --verbose` | — | Print auth/request/pagination tracing to stderr |
|
|
121
|
+
| `--no-formatted-values` | — | Do not send the default `Prefer` header for formatted values |
|
|
122
|
+
| `-H, --header` | `<name:value>` | Add a request header; may be repeated |
|
|
98
123
|
| `--whoami` | — | Call `WhoAmI` and print the response |
|
|
99
124
|
| `--version` | — | Print the package version |
|
|
100
125
|
| `--help` | — | Show help text |
|
|
@@ -104,6 +129,8 @@ Notes:
|
|
|
104
129
|
- If neither `[query]` nor `--file` is given, `dvq` reads stdin when input is piped.
|
|
105
130
|
- Without `--all`, the CLI prints the first JSON response object exactly as returned by Dataverse.
|
|
106
131
|
- With `--all`, the CLI prints a JSON array aggregated across pages.
|
|
132
|
+
- By default, `dvq` sends `Prefer: odata.include-annotations="OData.Community.Display.V1.FormattedValue"`.
|
|
133
|
+
- `--verbose` writes tracing to stderr and never prints the bearer token.
|
|
107
134
|
|
|
108
135
|
## Library API
|
|
109
136
|
|
|
@@ -141,10 +168,10 @@ console.log(rows);
|
|
|
141
168
|
| `resolveDataverseUrl`, `getDataverseUrl` | Resolve the Dataverse base URL from an explicit value or `DATAVERSE_URL` |
|
|
142
169
|
| `getDataverseScope` | Build the `/.default` scope for Azure auth |
|
|
143
170
|
| `buildUrl` | Build a full Dataverse Web API URL from an OData path |
|
|
144
|
-
| `buildHeaders` | Create request headers for Dataverse JSON calls |
|
|
171
|
+
| `buildHeaders` | Create request headers for Dataverse JSON calls, with optional annotation/header overrides |
|
|
145
172
|
| `getToken` | Acquire an access token using `AzureCliCredential` |
|
|
146
|
-
| `fetchOData` | Execute one HTTP request and parse the JSON response |
|
|
147
|
-
| `queryAll` | Follow paginated `@odata.nextLink` responses and return one array |
|
|
173
|
+
| `fetchOData` | Execute one HTTP request and parse the JSON response, with optional request overrides |
|
|
174
|
+
| `queryAll` | Follow paginated `@odata.nextLink` responses and return one array, with optional request overrides |
|
|
148
175
|
| `readQueryFile`, `readStdin` | CLI-oriented input helpers |
|
|
149
176
|
| `ODataError` | Error type for non-2xx HTTP responses |
|
|
150
177
|
| `API_PATH`, `DEFAULT_API_PATH`, `MAX_PAGES` | Public constants used by the helpers |
|
|
@@ -162,10 +189,19 @@ Authentication failure:
|
|
|
162
189
|
```text
|
|
163
190
|
Failed to get token. Run:
|
|
164
191
|
az login
|
|
192
|
+
Target: https://yourorg.crm.dynamics.com
|
|
165
193
|
```
|
|
166
194
|
|
|
167
195
|
`queryAll()` stops after `MAX_PAGES` pages and throws if the safety cap is exceeded.
|
|
168
196
|
|
|
197
|
+
Dataverse request failures now include the request URL and, when possible, a targeted troubleshooting hint. Example for the header-related 400 seen on some entity reads:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
HTTP 400: Both header name and value should be specified.
|
|
201
|
+
URL: https://yourorg.crm.dynamics.com/api/data/v9.2/msp_accountteams?$top=5
|
|
202
|
+
Hint: Dataverse rejected a request header. Retry with formatted values disabled to isolate the default Prefer header, and review any custom headers.
|
|
203
|
+
```
|
|
204
|
+
|
|
169
205
|
## Contributing
|
|
170
206
|
|
|
171
207
|
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
export declare function parseHeaders(headerValues?: string[]): Record<string, string>;
|
|
3
4
|
export declare function createProgram(version?: string): Command;
|
|
4
5
|
export declare function runCli(argv?: string[]): Promise<void>;
|
|
6
|
+
export declare function isCliEntrypoint(metaUrl: string, argv?: readonly string[]): boolean;
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { Command } from 'commander';
|
|
5
7
|
import { MAX_PAGES, buildUrl, fetchOData, getDataverseUrl, getToken, queryAll, readQueryFile, readStdin, } from './lib.js';
|
|
6
8
|
const require = createRequire(import.meta.url);
|
|
7
9
|
const { version: VERSION } = require('../package.json');
|
|
10
|
+
const INVALID_HEADER_GUIDANCE = 'Invalid header. Use the form "Name: Value".';
|
|
8
11
|
const HELP_AFTER = `
|
|
9
12
|
Environment:
|
|
10
13
|
DATAVERSE_URL Base URL for your Dataverse org (required unless --url is provided)
|
|
@@ -15,9 +18,43 @@ Prerequisites:
|
|
|
15
18
|
Examples:
|
|
16
19
|
dvq --url https://yourorg.crm.dynamics.com --whoami
|
|
17
20
|
dvq --file query.odata --all
|
|
21
|
+
dvq --verbose --url https://yourorg.crm.dynamics.com --file query.odata
|
|
22
|
+
dvq --url https://yourorg.crm.dynamics.com --no-formatted-values "accounts?$top=5"
|
|
23
|
+
dvq --url https://yourorg.crm.dynamics.com -H "ConsistencyLevel: eventual" "accounts?$top=5"
|
|
18
24
|
DATAVERSE_URL=https://yourorg.crm.dynamics.com dvq "accounts?\\$top=5"
|
|
19
25
|
echo "accounts?\\$top=5" | dvq --url https://yourorg.crm.dynamics.com
|
|
20
26
|
`;
|
|
27
|
+
function collectHeader(value, previous = []) {
|
|
28
|
+
previous.push(value);
|
|
29
|
+
return previous;
|
|
30
|
+
}
|
|
31
|
+
export function parseHeaders(headerValues = []) {
|
|
32
|
+
return headerValues.reduce((headers, headerValue) => {
|
|
33
|
+
const separatorIndex = headerValue.indexOf(':');
|
|
34
|
+
if (separatorIndex <= 0) {
|
|
35
|
+
throw new Error(`${INVALID_HEADER_GUIDANCE} Received: ${headerValue}`);
|
|
36
|
+
}
|
|
37
|
+
const name = headerValue.slice(0, separatorIndex).trim();
|
|
38
|
+
const value = headerValue.slice(separatorIndex + 1).trim();
|
|
39
|
+
if (!name || !value) {
|
|
40
|
+
throw new Error(`${INVALID_HEADER_GUIDANCE} Received: ${headerValue}`);
|
|
41
|
+
}
|
|
42
|
+
headers[name] = value;
|
|
43
|
+
return headers;
|
|
44
|
+
}, {});
|
|
45
|
+
}
|
|
46
|
+
function toRequestOptions(opts) {
|
|
47
|
+
const logger = opts.verbose
|
|
48
|
+
? (message) => {
|
|
49
|
+
console.error(`[dvq] ${message}`);
|
|
50
|
+
}
|
|
51
|
+
: undefined;
|
|
52
|
+
return {
|
|
53
|
+
includeFormattedValues: opts.formattedValues,
|
|
54
|
+
headers: parseHeaders(opts.header),
|
|
55
|
+
logger,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
21
58
|
export function createProgram(version = VERSION) {
|
|
22
59
|
const program = new Command()
|
|
23
60
|
.name('dvq')
|
|
@@ -26,6 +63,9 @@ export function createProgram(version = VERSION) {
|
|
|
26
63
|
.option('-f, --file <path>', 'read an OData query path from a file')
|
|
27
64
|
.option('-a, --all', `follow @odata.nextLink pages (max ${MAX_PAGES})`)
|
|
28
65
|
.option('-u, --url <url>', 'use this Dataverse base URL for the request')
|
|
66
|
+
.option('-v, --verbose', 'print request tracing to stderr')
|
|
67
|
+
.option('--no-formatted-values', 'do not request OData formatted value annotations')
|
|
68
|
+
.option('-H, --header <name:value>', 'add a request header; may be repeated', collectHeader, [])
|
|
29
69
|
.option('--whoami', 'print the WhoAmI response to verify auth')
|
|
30
70
|
.argument('[query]', 'inline OData query path (everything after /api/data/v9.2/)')
|
|
31
71
|
.addHelpText('after', HELP_AFTER);
|
|
@@ -44,18 +84,22 @@ export function createProgram(version = VERSION) {
|
|
|
44
84
|
program.help();
|
|
45
85
|
}
|
|
46
86
|
const baseUrl = getDataverseUrl(opts.url);
|
|
47
|
-
const
|
|
87
|
+
const requestOptions = toRequestOptions(opts);
|
|
88
|
+
const token = await getToken({
|
|
89
|
+
baseUrl,
|
|
90
|
+
logger: requestOptions.logger,
|
|
91
|
+
});
|
|
48
92
|
if (opts.whoami) {
|
|
49
|
-
const data = await fetchOData(buildUrl('WhoAmI', baseUrl), token);
|
|
93
|
+
const data = await fetchOData(buildUrl('WhoAmI', baseUrl), token, requestOptions);
|
|
50
94
|
console.log(JSON.stringify(data, null, 2));
|
|
51
95
|
return;
|
|
52
96
|
}
|
|
53
97
|
if (opts.all) {
|
|
54
|
-
const results = await queryAll(query, token, baseUrl);
|
|
98
|
+
const results = await queryAll(query, token, baseUrl, process.env, requestOptions);
|
|
55
99
|
console.log(JSON.stringify(results, null, 2));
|
|
56
100
|
return;
|
|
57
101
|
}
|
|
58
|
-
const data = await fetchOData(buildUrl(query, baseUrl), token);
|
|
102
|
+
const data = await fetchOData(buildUrl(query, baseUrl), token, requestOptions);
|
|
59
103
|
console.log(JSON.stringify(data, null, 2));
|
|
60
104
|
});
|
|
61
105
|
return program;
|
|
@@ -63,10 +107,14 @@ export function createProgram(version = VERSION) {
|
|
|
63
107
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
64
108
|
await createProgram().parseAsync(argv, { from: 'user' });
|
|
65
109
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
110
|
+
export function isCliEntrypoint(metaUrl, argv = process.argv) {
|
|
111
|
+
const entrypoint = argv[1];
|
|
112
|
+
if (!entrypoint) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(resolve(entrypoint));
|
|
116
|
+
}
|
|
117
|
+
if (import.meta.main ?? isCliEntrypoint(import.meta.url)) {
|
|
70
118
|
runCli().catch((error) => {
|
|
71
119
|
const message = error instanceof Error ? error.message : String(error);
|
|
72
120
|
console.error(message);
|
package/dist/lib.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export declare const AZ_LOGIN_GUIDANCE = "Failed to get token. Run:\n az login"
|
|
|
3
3
|
export declare const API_PATH = "/api/data/v9.2/";
|
|
4
4
|
export declare const DEFAULT_API_PATH = "/api/data/v9.2/";
|
|
5
5
|
export declare const MAX_PAGES = 40;
|
|
6
|
+
export type TraceLogger = (message: string) => void;
|
|
7
|
+
export interface RequestOptions {
|
|
8
|
+
includeFormattedValues?: boolean;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
logger?: TraceLogger;
|
|
11
|
+
}
|
|
6
12
|
export interface ResolveDataverseUrlOptions {
|
|
7
13
|
env?: NodeJS.ProcessEnv;
|
|
8
14
|
url?: string | undefined;
|
|
@@ -20,22 +26,35 @@ interface ODataResponse {
|
|
|
20
26
|
};
|
|
21
27
|
[key: string]: unknown;
|
|
22
28
|
}
|
|
29
|
+
export declare function getODataErrorHint(statusCode: number, detail: string): string | undefined;
|
|
30
|
+
export declare function formatODataErrorMessage(statusCode: number, detail: string, options?: {
|
|
31
|
+
requestUrl?: string;
|
|
32
|
+
hint?: string;
|
|
33
|
+
}): string;
|
|
34
|
+
export declare function formatAuthFailureMessage(baseUrl: string): string;
|
|
23
35
|
export declare function resolveDataverseUrl(options?: ResolveDataverseUrlOptions): string;
|
|
24
36
|
export declare function getDataverseUrl(baseUrl?: string, env?: NodeJS.ProcessEnv): string;
|
|
25
37
|
export declare function getDataverseScope(baseUrl?: string, env?: NodeJS.ProcessEnv): string;
|
|
26
38
|
export declare function buildUrl(odataPath: string, baseUrl?: string, env?: NodeJS.ProcessEnv): string;
|
|
27
|
-
export declare function buildHeaders(token: string): Record<string, string>;
|
|
39
|
+
export declare function buildHeaders(token: string, options?: RequestOptions): Record<string, string>;
|
|
28
40
|
export declare function getToken(options?: {
|
|
29
41
|
baseUrl?: string;
|
|
30
42
|
credential?: TokenCredentialLike;
|
|
31
43
|
env?: NodeJS.ProcessEnv;
|
|
44
|
+
logger?: TraceLogger;
|
|
32
45
|
}): Promise<string>;
|
|
33
46
|
export declare class ODataError extends Error {
|
|
34
47
|
statusCode: number;
|
|
35
|
-
|
|
48
|
+
detail: string;
|
|
49
|
+
hint?: string;
|
|
50
|
+
requestUrl?: string;
|
|
51
|
+
constructor(statusCode: number, detail: string, options?: {
|
|
52
|
+
hint?: string;
|
|
53
|
+
requestUrl?: string;
|
|
54
|
+
});
|
|
36
55
|
}
|
|
37
|
-
export declare function fetchOData(url: string, token: string): Promise<ODataResponse>;
|
|
38
|
-
export declare function queryAll(odataPath: string, token: string, baseUrl?: string, env?: NodeJS.ProcessEnv): Promise<unknown[]>;
|
|
56
|
+
export declare function fetchOData(url: string, token: string, options?: RequestOptions): Promise<ODataResponse>;
|
|
57
|
+
export declare function queryAll(odataPath: string, token: string, baseUrl?: string, env?: NodeJS.ProcessEnv, options?: RequestOptions): Promise<unknown[]>;
|
|
39
58
|
export declare function readQueryFile(filePath: string): string;
|
|
40
59
|
export declare function readStdin(): Promise<string>;
|
|
41
60
|
export {};
|
package/dist/lib.js
CHANGED
|
@@ -5,6 +5,50 @@ export const AZ_LOGIN_GUIDANCE = 'Failed to get token. Run:\n az login';
|
|
|
5
5
|
export const API_PATH = '/api/data/v9.2/';
|
|
6
6
|
export const DEFAULT_API_PATH = API_PATH;
|
|
7
7
|
export const MAX_PAGES = 40;
|
|
8
|
+
function formatDiagnosticBlock(label, value) {
|
|
9
|
+
return `${label}: ${value}`;
|
|
10
|
+
}
|
|
11
|
+
function trace(logger, message) {
|
|
12
|
+
logger?.(message);
|
|
13
|
+
}
|
|
14
|
+
function sanitizeHeadersForLogging(headers) {
|
|
15
|
+
return Object.fromEntries(Object.entries(headers).map(([name, value]) => [
|
|
16
|
+
name,
|
|
17
|
+
name.toLowerCase() === 'authorization' ? '<redacted>' : value,
|
|
18
|
+
]));
|
|
19
|
+
}
|
|
20
|
+
export function getODataErrorHint(statusCode, detail) {
|
|
21
|
+
if (statusCode === 400) {
|
|
22
|
+
if (detail.includes('Both header name and value should be specified.')) {
|
|
23
|
+
return 'Dataverse rejected a request header. Retry with formatted values disabled to isolate the default Prefer header, and review any custom headers.';
|
|
24
|
+
}
|
|
25
|
+
return 'Check the OData path, entity set names, filter syntax, and any request headers. For CLI debugging, retry with --no-formatted-values to isolate header-related issues.';
|
|
26
|
+
}
|
|
27
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
28
|
+
return 'Run az login again and verify that the selected account has access to this Dataverse environment.';
|
|
29
|
+
}
|
|
30
|
+
if (statusCode === 404) {
|
|
31
|
+
return 'Verify the entity set name, record ID, and OData path.';
|
|
32
|
+
}
|
|
33
|
+
if (statusCode === 429) {
|
|
34
|
+
return 'The environment is throttling requests. Retry after a delay or narrow the query.';
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function formatODataErrorMessage(statusCode, detail, options = {}) {
|
|
39
|
+
const lines = [`HTTP ${statusCode}: ${detail}`];
|
|
40
|
+
if (options.requestUrl) {
|
|
41
|
+
lines.push(formatDiagnosticBlock('URL', options.requestUrl));
|
|
42
|
+
}
|
|
43
|
+
const hint = options.hint ?? getODataErrorHint(statusCode, detail);
|
|
44
|
+
if (hint) {
|
|
45
|
+
lines.push(formatDiagnosticBlock('Hint', hint));
|
|
46
|
+
}
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
export function formatAuthFailureMessage(baseUrl) {
|
|
50
|
+
return [AZ_LOGIN_GUIDANCE, formatDiagnosticBlock('Target', baseUrl)].join('\n');
|
|
51
|
+
}
|
|
8
52
|
function normalizeOptionalValue(value) {
|
|
9
53
|
const trimmed = value?.trim();
|
|
10
54
|
return trimmed ? trimmed : undefined;
|
|
@@ -47,38 +91,61 @@ export function buildUrl(odataPath, baseUrl, env = process.env) {
|
|
|
47
91
|
const queryPath = trimLeadingSlashes(odataPath);
|
|
48
92
|
return `${getDataverseUrl(baseUrl, env)}${API_PATH}${queryPath}`;
|
|
49
93
|
}
|
|
50
|
-
export function buildHeaders(token) {
|
|
51
|
-
|
|
94
|
+
export function buildHeaders(token, options = {}) {
|
|
95
|
+
const headers = {
|
|
52
96
|
Authorization: `Bearer ${token}`,
|
|
53
97
|
'OData-MaxVersion': '4.0',
|
|
54
98
|
'OData-Version': '4.0',
|
|
55
99
|
Accept: 'application/json',
|
|
56
|
-
|
|
100
|
+
};
|
|
101
|
+
if (options.includeFormattedValues !== false) {
|
|
102
|
+
headers.Prefer =
|
|
103
|
+
'odata.include-annotations="OData.Community.Display.V1.FormattedValue"';
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
...headers,
|
|
107
|
+
...options.headers,
|
|
57
108
|
};
|
|
58
109
|
}
|
|
59
110
|
export async function getToken(options = {}) {
|
|
111
|
+
const baseUrl = getDataverseUrl(options.baseUrl, options.env);
|
|
60
112
|
const credential = options.credential ?? new AzureCliCredential();
|
|
113
|
+
const scope = getDataverseScope(baseUrl);
|
|
114
|
+
trace(options.logger, `Auth target: ${baseUrl}`);
|
|
115
|
+
trace(options.logger, `Auth scope: ${scope}`);
|
|
61
116
|
try {
|
|
62
|
-
const response = await credential.getToken(
|
|
117
|
+
const response = await credential.getToken(scope);
|
|
63
118
|
if (!response?.token) {
|
|
64
119
|
throw new Error('Missing token response');
|
|
65
120
|
}
|
|
121
|
+
trace(options.logger, 'Token acquired successfully');
|
|
66
122
|
return response.token;
|
|
67
123
|
}
|
|
68
124
|
catch {
|
|
69
|
-
|
|
125
|
+
trace(options.logger, 'Token acquisition failed');
|
|
126
|
+
throw new Error(formatAuthFailureMessage(baseUrl));
|
|
70
127
|
}
|
|
71
128
|
}
|
|
72
129
|
export class ODataError extends Error {
|
|
73
130
|
statusCode;
|
|
74
|
-
|
|
75
|
-
|
|
131
|
+
detail;
|
|
132
|
+
hint;
|
|
133
|
+
requestUrl;
|
|
134
|
+
constructor(statusCode, detail, options = {}) {
|
|
135
|
+
super(formatODataErrorMessage(statusCode, detail, options));
|
|
76
136
|
this.statusCode = statusCode;
|
|
77
137
|
this.name = 'ODataError';
|
|
138
|
+
this.detail = detail;
|
|
139
|
+
this.hint = options.hint ?? getODataErrorHint(statusCode, detail);
|
|
140
|
+
this.requestUrl = options.requestUrl;
|
|
78
141
|
}
|
|
79
142
|
}
|
|
80
|
-
export async function fetchOData(url, token) {
|
|
81
|
-
const
|
|
143
|
+
export async function fetchOData(url, token, options = {}) {
|
|
144
|
+
const headers = buildHeaders(token, options);
|
|
145
|
+
trace(options.logger, `GET ${url}`);
|
|
146
|
+
trace(options.logger, `Headers ${JSON.stringify(sanitizeHeadersForLogging(headers))}`);
|
|
147
|
+
const response = await fetch(url, { headers });
|
|
148
|
+
trace(options.logger, `Response ${response.status} ${response.statusText}`);
|
|
82
149
|
if (!response.ok) {
|
|
83
150
|
let detail;
|
|
84
151
|
try {
|
|
@@ -88,27 +155,39 @@ export async function fetchOData(url, token) {
|
|
|
88
155
|
catch {
|
|
89
156
|
detail = response.statusText;
|
|
90
157
|
}
|
|
91
|
-
|
|
158
|
+
trace(options.logger, `Error detail ${detail}`);
|
|
159
|
+
throw new ODataError(response.status, detail, {
|
|
160
|
+
hint: getODataErrorHint(response.status, detail),
|
|
161
|
+
requestUrl: url,
|
|
162
|
+
});
|
|
92
163
|
}
|
|
93
|
-
|
|
164
|
+
const data = await response.json();
|
|
165
|
+
trace(options.logger, 'Response body parsed successfully');
|
|
166
|
+
return data;
|
|
94
167
|
}
|
|
95
|
-
export async function queryAll(odataPath, token, baseUrl, env = process.env) {
|
|
168
|
+
export async function queryAll(odataPath, token, baseUrl, env = process.env, options = {}) {
|
|
96
169
|
let url = buildUrl(odataPath, baseUrl, env);
|
|
97
170
|
const results = [];
|
|
98
171
|
let hasNextPage = false;
|
|
172
|
+
trace(options.logger, `Starting paged query for ${odataPath}`);
|
|
99
173
|
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
100
|
-
|
|
174
|
+
trace(options.logger, `Fetching page ${page + 1}`);
|
|
175
|
+
const data = await fetchOData(url, token, options);
|
|
101
176
|
if (data.value) {
|
|
102
177
|
results.push(...data.value);
|
|
178
|
+
trace(options.logger, `Collected ${results.length} rows total`);
|
|
103
179
|
}
|
|
104
180
|
else {
|
|
181
|
+
trace(options.logger, 'Received a non-collection response; returning wrapped result');
|
|
105
182
|
return [data];
|
|
106
183
|
}
|
|
107
184
|
const nextLink = data['@odata.nextLink'];
|
|
108
185
|
hasNextPage = Boolean(nextLink);
|
|
109
186
|
if (!nextLink) {
|
|
187
|
+
trace(options.logger, `Pagination complete after ${page + 1} page(s)`);
|
|
110
188
|
break;
|
|
111
189
|
}
|
|
190
|
+
trace(options.logger, 'Following @odata.nextLink');
|
|
112
191
|
url = nextLink;
|
|
113
192
|
}
|
|
114
193
|
if (hasNextPage) {
|