@sbroenne/dvq 0.1.3 → 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 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,5 +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>;
5
6
  export declare function isCliEntrypoint(metaUrl: string, argv?: readonly string[]): boolean;
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { Command } from 'commander';
7
7
  import { MAX_PAGES, buildUrl, fetchOData, getDataverseUrl, getToken, queryAll, readQueryFile, readStdin, } from './lib.js';
8
8
  const require = createRequire(import.meta.url);
9
9
  const { version: VERSION } = require('../package.json');
10
+ const INVALID_HEADER_GUIDANCE = 'Invalid header. Use the form "Name: Value".';
10
11
  const HELP_AFTER = `
11
12
  Environment:
12
13
  DATAVERSE_URL Base URL for your Dataverse org (required unless --url is provided)
@@ -17,9 +18,43 @@ Prerequisites:
17
18
  Examples:
18
19
  dvq --url https://yourorg.crm.dynamics.com --whoami
19
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"
20
24
  DATAVERSE_URL=https://yourorg.crm.dynamics.com dvq "accounts?\\$top=5"
21
25
  echo "accounts?\\$top=5" | dvq --url https://yourorg.crm.dynamics.com
22
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
+ }
23
58
  export function createProgram(version = VERSION) {
24
59
  const program = new Command()
25
60
  .name('dvq')
@@ -28,6 +63,9 @@ export function createProgram(version = VERSION) {
28
63
  .option('-f, --file <path>', 'read an OData query path from a file')
29
64
  .option('-a, --all', `follow @odata.nextLink pages (max ${MAX_PAGES})`)
30
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, [])
31
69
  .option('--whoami', 'print the WhoAmI response to verify auth')
32
70
  .argument('[query]', 'inline OData query path (everything after /api/data/v9.2/)')
33
71
  .addHelpText('after', HELP_AFTER);
@@ -46,18 +84,22 @@ export function createProgram(version = VERSION) {
46
84
  program.help();
47
85
  }
48
86
  const baseUrl = getDataverseUrl(opts.url);
49
- const token = await getToken({ baseUrl });
87
+ const requestOptions = toRequestOptions(opts);
88
+ const token = await getToken({
89
+ baseUrl,
90
+ logger: requestOptions.logger,
91
+ });
50
92
  if (opts.whoami) {
51
- const data = await fetchOData(buildUrl('WhoAmI', baseUrl), token);
93
+ const data = await fetchOData(buildUrl('WhoAmI', baseUrl), token, requestOptions);
52
94
  console.log(JSON.stringify(data, null, 2));
53
95
  return;
54
96
  }
55
97
  if (opts.all) {
56
- const results = await queryAll(query, token, baseUrl);
98
+ const results = await queryAll(query, token, baseUrl, process.env, requestOptions);
57
99
  console.log(JSON.stringify(results, null, 2));
58
100
  return;
59
101
  }
60
- const data = await fetchOData(buildUrl(query, baseUrl), token);
102
+ const data = await fetchOData(buildUrl(query, baseUrl), token, requestOptions);
61
103
  console.log(JSON.stringify(data, null, 2));
62
104
  });
63
105
  return program;
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
- constructor(statusCode: number, detail: string);
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
- return {
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
- Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"',
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(getDataverseScope(options.baseUrl, options.env));
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
- throw new Error(AZ_LOGIN_GUIDANCE);
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
- constructor(statusCode, detail) {
75
- super(`HTTP ${statusCode}: ${detail}`);
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 response = await fetch(url, { headers: buildHeaders(token) });
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
- throw new ODataError(response.status, detail);
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
- return response.json();
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
- const data = await fetchOData(url, token);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sbroenne/dvq",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CLI for querying Dataverse OData endpoints with Azure CLI credentials",
5
5
  "type": "module",
6
6
  "bin": {