Skip to content

Commit 03311ae

Browse files
committed
✨ split API review commands
Group the build, baseline, comparison, project, review, and upload command changes into one reviewable slice.
1 parent fbd32f6 commit 03311ae

15 files changed

Lines changed: 1758 additions & 393 deletions

src/commands/api.js

Lines changed: 153 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,132 @@ import { createApiClient as defaultCreateApiClient } from '../api/index.js';
66
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
77
import * as defaultOutput from '../utils/output.js';
88

9+
let ALLOWED_POST_ENDPOINTS = [
10+
/^\/api\/sdk\/comparisons\/[^/]+\/approve$/,
11+
/^\/api\/sdk\/comparisons\/[^/]+\/reject$/,
12+
/^\/api\/sdk\/builds\/[^/]+\/comments$/,
13+
];
14+
15+
function createApiCommandDeps(deps = {}) {
16+
return {
17+
loadConfig: deps.loadConfig || defaultLoadConfig,
18+
createApiClient: deps.createApiClient || defaultCreateApiClient,
19+
output: deps.output || defaultOutput,
20+
exit: deps.exit || (code => process.exit(code)),
21+
};
22+
}
23+
24+
function configureOutput(output, globalOptions) {
25+
output.configure({
26+
json: globalOptions.json,
27+
verbose: globalOptions.verbose,
28+
color: !globalOptions.noColor,
29+
});
30+
}
31+
32+
export function normalizeApiEndpoint(endpoint) {
33+
let normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
34+
35+
if (!normalizedEndpoint.startsWith('/api/')) {
36+
normalizedEndpoint = `/api${normalizedEndpoint}`;
37+
}
38+
39+
return normalizedEndpoint;
40+
}
41+
42+
export function normalizeApiMethod(method = 'GET') {
43+
return method.toUpperCase();
44+
}
45+
46+
export function isAllowedPostEndpoint(endpoint) {
47+
return ALLOWED_POST_ENDPOINTS.some(pattern => pattern.test(endpoint));
48+
}
49+
50+
export function parseApiHeaders(headerOption) {
51+
let headers = {};
52+
let headerList = Array.isArray(headerOption) ? headerOption : [headerOption];
53+
54+
for (let header of headerList.filter(Boolean)) {
55+
let [key, ...valueParts] = header.split(':');
56+
if (key && valueParts.length > 0) {
57+
headers[key.trim()] = valueParts.join(':').trim();
58+
}
59+
}
60+
61+
return headers;
62+
}
63+
64+
export function appendApiQuery(endpoint, queryOption) {
65+
if (!queryOption) {
66+
return endpoint;
67+
}
68+
69+
let params = new URLSearchParams();
70+
let queryList = Array.isArray(queryOption) ? queryOption : [queryOption];
71+
72+
for (let query of queryList) {
73+
let [key, ...valueParts] = query.split('=');
74+
if (key && valueParts.length > 0) {
75+
params.append(key.trim(), valueParts.join('=').trim());
76+
}
77+
}
78+
79+
let queryString = params.toString();
80+
if (!queryString) {
81+
return endpoint;
82+
}
83+
84+
return endpoint + (endpoint.includes('?') ? '&' : '?') + queryString;
85+
}
86+
87+
export function validateApiRequest({ endpoint, method }) {
88+
let errors = [];
89+
90+
if (method !== 'GET' && method !== 'POST') {
91+
errors.push(
92+
`Method ${method} not allowed. Use GET for queries or POST for approve/reject/comment.`
93+
);
94+
return errors;
95+
}
96+
97+
if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
98+
errors.push(
99+
`POST not allowed for ${endpoint}. Only approve, reject, and comment endpoints support POST.`
100+
);
101+
}
102+
103+
return errors;
104+
}
105+
106+
export function buildApiRequest({ endpoint, options = {} }) {
107+
let normalizedEndpoint = normalizeApiEndpoint(endpoint);
108+
let method = normalizeApiMethod(options.method || 'GET');
109+
let errors = validateApiRequest({ endpoint: normalizedEndpoint, method });
110+
111+
if (errors.length > 0) {
112+
return { errors, method, normalizedEndpoint, requestOptions: null };
113+
}
114+
115+
let headers = parseApiHeaders(options.header);
116+
let requestOptions = { method };
117+
118+
if (options.data && method === 'POST') {
119+
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
120+
requestOptions.body = options.data;
121+
}
122+
123+
if (Object.keys(headers).length > 0) {
124+
requestOptions.headers = headers;
125+
}
126+
127+
return {
128+
errors: [],
129+
method,
130+
normalizedEndpoint: appendApiQuery(normalizedEndpoint, options.query),
131+
requestOptions,
132+
};
133+
}
134+
9135
/**
10136
* API command - make raw API requests
11137
* @param {string} endpoint - API endpoint (e.g., /sdk/builds)
@@ -19,18 +145,12 @@ export async function apiCommand(
19145
globalOptions = {},
20146
deps = {}
21147
) {
22-
let {
23-
loadConfig = defaultLoadConfig,
24-
createApiClient = defaultCreateApiClient,
25-
output = defaultOutput,
26-
exit = code => process.exit(code),
27-
} = deps;
148+
let { loadConfig, createApiClient, output, exit } =
149+
createApiCommandDeps(deps);
150+
let displayEndpoint = normalizeApiEndpoint(endpoint);
151+
let displayMethod = normalizeApiMethod(options.method || 'GET');
28152

29-
output.configure({
30-
json: globalOptions.json,
31-
verbose: globalOptions.verbose,
32-
color: !globalOptions.noColor,
33-
});
153+
configureOutput(output, globalOptions);
34154

35155
try {
36156
// Load configuration
@@ -42,84 +162,32 @@ export async function apiCommand(
42162
output.error(
43163
'API token required. Use --token or set VIZZLY_TOKEN environment variable'
44164
);
165+
output.cleanup();
45166
exit(1);
46167
return;
47168
}
48169

49-
// Normalize endpoint
50-
let normalizedEndpoint = endpoint.startsWith('/')
51-
? endpoint
52-
: `/${endpoint}`;
53-
if (!normalizedEndpoint.startsWith('/api/')) {
54-
normalizedEndpoint = `/api${normalizedEndpoint}`;
55-
}
170+
let { errors, method, normalizedEndpoint, requestOptions } =
171+
buildApiRequest({ endpoint, options });
56172

57-
// Build request options
58-
let method = (options.method || 'GET').toUpperCase();
173+
displayEndpoint = normalizedEndpoint;
174+
displayMethod = method;
59175

60-
// Validate method and endpoint combination
61-
if (method === 'POST' && !isAllowedPostEndpoint(normalizedEndpoint)) {
62-
output.error(
63-
`POST not allowed for ${normalizedEndpoint}. Only approve, reject, and comment endpoints support POST.`
64-
);
176+
if (errors.length > 0) {
177+
output.error(errors[0]);
178+
if (method === 'POST') {
179+
output.hint(
180+
'Use GET for queries, or use dedicated commands (vizzly approve, vizzly reject, vizzly comment)'
181+
);
182+
}
65183
output.hint(
66-
'Use GET for queries, or use dedicated commands (vizzly approve, vizzly reject, vizzly comment)'
184+
'Most raw API use should stay read-only; prefer dedicated commands for mutations.'
67185
);
186+
output.cleanup();
68187
exit(1);
69188
return;
70189
}
71190

72-
if (method !== 'GET' && method !== 'POST') {
73-
output.error(`Method ${method} not allowed. Use GET for queries.`);
74-
exit(1);
75-
return;
76-
}
77-
78-
let requestOptions = { method };
79-
80-
// Add headers
81-
let headers = {};
82-
if (options.header) {
83-
let headerList = Array.isArray(options.header)
84-
? options.header
85-
: [options.header];
86-
for (let h of headerList) {
87-
let [key, ...valueParts] = h.split(':');
88-
if (key && valueParts.length > 0) {
89-
headers[key.trim()] = valueParts.join(':').trim();
90-
}
91-
}
92-
}
93-
94-
// Add body for POST/PUT/PATCH
95-
if (options.data && ['POST', 'PUT', 'PATCH'].includes(method)) {
96-
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
97-
requestOptions.body = options.data;
98-
}
99-
100-
if (Object.keys(headers).length > 0) {
101-
requestOptions.headers = headers;
102-
}
103-
104-
// Add query parameters
105-
if (options.query) {
106-
let params = new URLSearchParams();
107-
let queryList = Array.isArray(options.query)
108-
? options.query
109-
: [options.query];
110-
for (let q of queryList) {
111-
let [key, ...valueParts] = q.split('=');
112-
if (key && valueParts.length > 0) {
113-
params.append(key.trim(), valueParts.join('=').trim());
114-
}
115-
}
116-
let queryString = params.toString();
117-
if (queryString) {
118-
normalizedEndpoint +=
119-
(normalizedEndpoint.includes('?') ? '&' : '?') + queryString;
120-
}
121-
}
122-
123191
// Make the request
124192
output.startSpinner(`${method} ${normalizedEndpoint}`);
125193

@@ -162,8 +230,8 @@ export async function apiCommand(
162230

163231
if (globalOptions.json) {
164232
output.data({
165-
endpoint,
166-
method: options.method || 'GET',
233+
endpoint: displayEndpoint,
234+
method: displayMethod,
167235
error: {
168236
message: error.message,
169237
code: error.code,
@@ -181,23 +249,6 @@ export async function apiCommand(
181249
}
182250
}
183251

184-
/**
185-
* Allowed POST endpoints (whitelist for mutations)
186-
* Most mutations should use dedicated commands, but these are allowed for raw API access
187-
*/
188-
const ALLOWED_POST_ENDPOINTS = [
189-
/^\/api\/sdk\/comparisons\/[^/]+\/approve$/,
190-
/^\/api\/sdk\/comparisons\/[^/]+\/reject$/,
191-
/^\/api\/sdk\/builds\/[^/]+\/comments$/,
192-
];
193-
194-
/**
195-
* Check if a POST endpoint is allowed
196-
*/
197-
function isAllowedPostEndpoint(endpoint) {
198-
return ALLOWED_POST_ENDPOINTS.some(pattern => pattern.test(endpoint));
199-
}
200-
201252
/**
202253
* Validate API command options
203254
*/
@@ -208,15 +259,13 @@ export function validateApiOptions(endpoint, options = {}) {
208259
errors.push('Endpoint is required');
209260
}
210261

211-
let method = (options.method || 'GET').toUpperCase();
212-
213-
// Only GET is allowed by default
214-
// POST is allowed only for whitelisted endpoints
215-
if (method !== 'GET' && method !== 'POST') {
216-
errors.push(
217-
`Method ${method} not allowed. Use GET for queries or POST for approve/reject/comment.`
218-
);
262+
if (!endpoint || endpoint.trim() === '') {
263+
return errors;
219264
}
220265

266+
let normalizedEndpoint = normalizeApiEndpoint(endpoint);
267+
let method = normalizeApiMethod(options.method || 'GET');
268+
errors.push(...validateApiRequest({ endpoint: normalizedEndpoint, method }));
269+
221270
return errors;
222271
}

0 commit comments

Comments
 (0)