@@ -6,6 +6,132 @@ import { createApiClient as defaultCreateApiClient } from '../api/index.js';
66import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js' ;
77import * as defaultOutput from '../utils/output.js' ;
88
9+ let ALLOWED_POST_ENDPOINTS = [
10+ / ^ \/ a p i \/ s d k \/ c o m p a r i s o n s \/ [ ^ / ] + \/ a p p r o v e $ / ,
11+ / ^ \/ a p i \/ s d k \/ c o m p a r i s o n s \/ [ ^ / ] + \/ r e j e c t $ / ,
12+ / ^ \/ a p i \/ s d k \/ b u i l d s \/ [ ^ / ] + \/ c o m m e n t s $ / ,
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- / ^ \/ a p i \/ s d k \/ c o m p a r i s o n s \/ [ ^ / ] + \/ a p p r o v e $ / ,
190- / ^ \/ a p i \/ s d k \/ c o m p a r i s o n s \/ [ ^ / ] + \/ r e j e c t $ / ,
191- / ^ \/ a p i \/ s d k \/ b u i l d s \/ [ ^ / ] + \/ c o m m e n t s $ / ,
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