-
Notifications
You must be signed in to change notification settings - Fork 0
[codex] add qasphere api command #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,96 @@ | ||||||
| import { readFile } from 'node:fs/promises' | ||||||
| import { basename } from 'node:path' | ||||||
|
|
||||||
| import { CLI_VERSION } from '../utils/version' | ||||||
| import { withBaseUrl, withHeaders, withHttpRetry } from './utils' | ||||||
|
|
||||||
| export interface PublicApiRequest { | ||||||
| method: 'GET' | 'POST' | 'PATCH' | ||||||
| pathname: string | ||||||
| query?: URLSearchParams | ||||||
| jsonBody?: unknown | ||||||
| filePath?: string | ||||||
| } | ||||||
|
|
||||||
| const createPublicApiFetcher = (baseUrl: string, apiKey: string) => | ||||||
| withHttpRetry( | ||||||
| withHeaders(withBaseUrl(fetch, baseUrl), { | ||||||
| Authorization: `ApiKey ${apiKey}`, | ||||||
| Accept: 'application/json', | ||||||
| 'User-Agent': `qas-cli/${CLI_VERSION}`, | ||||||
| }) | ||||||
| ) | ||||||
|
|
||||||
| const buildErrorFromResponse = async (response: Response) => { | ||||||
| const contentType = response.headers.get('content-type') ?? '' | ||||||
| if (contentType.includes('application/json')) { | ||||||
| try { | ||||||
| const body = (await response.json()) as Record<string, unknown> | ||||||
| const message = typeof body.message === 'string' ? body.message : response.statusText | ||||||
| throw new Error(message) | ||||||
| } catch (error) { | ||||||
| if (error instanceof Error && error.message !== 'Unexpected end of JSON input') { | ||||||
| throw error | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| const text = await response.text() | ||||||
| throw new Error(text || response.statusText || `Request failed with status ${response.status}`) | ||||||
| } | ||||||
|
|
||||||
| const parseJsonResponse = async (response: Response) => { | ||||||
| if (response.status === 204) { | ||||||
| return null | ||||||
| } | ||||||
|
|
||||||
| const contentType = response.headers.get('content-type') ?? '' | ||||||
| if (!contentType.includes('application/json')) { | ||||||
| const text = await response.text() | ||||||
| return text ? JSON.parse(text) : null | ||||||
|
Comment on lines
+48
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In |
||||||
| } | ||||||
|
|
||||||
| const text = await response.text() | ||||||
| return text ? JSON.parse(text) : null | ||||||
|
Comment on lines
+42
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both the const parseJsonResponse = async (response: Response) => {
if (response.status === 204) {
return null
}
const text = await response.text()
return text ? JSON.parse(text) : null
}Also, for non-JSON content types, calling |
||||||
| } | ||||||
|
|
||||||
| export const executePublicApiRequest = async ( | ||||||
| baseUrl: string, | ||||||
| apiKey: string, | ||||||
| request: PublicApiRequest | ||||||
| ) => { | ||||||
| const fetcher = createPublicApiFetcher(baseUrl, apiKey) | ||||||
| const query = request.query?.size ? `?${request.query.toString()}` : '' | ||||||
| const url = `${request.pathname}${query}` | ||||||
|
|
||||||
| if (request.filePath) { | ||||||
| const fileBuffer = await readFile(request.filePath) | ||||||
| const formData = new FormData() | ||||||
| formData.append('file', new Blob([fileBuffer]), basename(request.filePath)) | ||||||
|
Comment on lines
+67
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For file uploads, |
||||||
| const response = await fetcher(url, { | ||||||
| method: request.method, | ||||||
| body: formData, | ||||||
| }) | ||||||
| if (!response.ok) { | ||||||
| return buildErrorFromResponse(response) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
And similarly for line 91-92: await buildErrorFromResponse(response) |
||||||
| } | ||||||
| return parseJsonResponse(response) | ||||||
| } | ||||||
|
|
||||||
| const response = await fetcher(url, { | ||||||
| method: request.method, | ||||||
| headers: | ||||||
| request.jsonBody === undefined | ||||||
| ? undefined | ||||||
| : { | ||||||
| 'Content-Type': 'application/json', | ||||||
| }, | ||||||
| body: request.jsonBody === undefined ? undefined : JSON.stringify(request.jsonBody), | ||||||
| }) | ||||||
|
|
||||||
| if (!response.ok) { | ||||||
| return buildErrorFromResponse(response) | ||||||
| } | ||||||
|
|
||||||
| return parseJsonResponse(response) | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,129 @@ | ||||||||||
| import { Readable } from 'node:stream' | ||||||||||
|
|
||||||||||
| import { executePublicApiRequest } from '../../api/publicApi' | ||||||||||
| import { loadEnvs } from '../../utils/env' | ||||||||||
| import { | ||||||||||
| ApiValidationError, | ||||||||||
| collectQueryInput, | ||||||||||
| buildPathname, | ||||||||||
| loadJsonBodyInput, | ||||||||||
| parseIntegerValue, | ||||||||||
| serializeQueryObject, | ||||||||||
| validateBodyMode, | ||||||||||
| validateWithSchema, | ||||||||||
| } from './helpers' | ||||||||||
| import { ApiEndpointSpec, ApiOptionSpec } from './types' | ||||||||||
|
|
||||||||||
| export interface ExecuteApiCommandOptions { | ||||||||||
| stdin?: Readable | ||||||||||
| baseUrl?: string | ||||||||||
| apiKey?: string | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const normalizeOptionValue = (option: ApiOptionSpec, value: unknown) => { | ||||||||||
| if (option.type !== 'integer') { | ||||||||||
| return value | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (option.array) { | ||||||||||
| const items = Array.isArray(value) ? value : [value] | ||||||||||
| return items.map((item, index) => parseIntegerValue(`--${option.name}[${index}]`, item)) | ||||||||||
|
Comment on lines
+29
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Suggested change
|
||||||||||
| } | ||||||||||
|
|
||||||||||
| return parseIntegerValue(`--${option.name}`, value) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const collectValidatedQuery = (spec: ApiEndpointSpec, args: Record<string, unknown>) => { | ||||||||||
| const query = collectQueryInput(args, spec.queryOptions, spec.supportsCustomFieldFilters) | ||||||||||
| const normalizedQuery = Object.fromEntries( | ||||||||||
| Object.entries(query).map(([key, value]) => { | ||||||||||
| const option = spec.queryOptions?.find((item) => item.name === key) | ||||||||||
| return [key, option ? normalizeOptionValue(option, value) : value] | ||||||||||
| }) | ||||||||||
| ) | ||||||||||
| const queryInput = spec.queryAdapter ? spec.queryAdapter(normalizedQuery) : normalizedQuery | ||||||||||
| return validateWithSchema<Record<string, unknown>>('Query', spec.querySchema, queryInput) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const collectPathParams = (spec: ApiEndpointSpec, args: Record<string, unknown>) => { | ||||||||||
| return Object.fromEntries( | ||||||||||
| spec.pathParams.map((param) => { | ||||||||||
| const rawValue = args[param.name] | ||||||||||
| if (param.type === 'integer') { | ||||||||||
| return [param.name, parseIntegerValue(`<${param.name}>`, rawValue)] | ||||||||||
| } | ||||||||||
| if (typeof rawValue !== 'string' || rawValue.length === 0) { | ||||||||||
| throw new ApiValidationError(`<${param.name}> is required.`) | ||||||||||
| } | ||||||||||
| return [param.name, rawValue] | ||||||||||
| }) | ||||||||||
| ) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const collectBody = async ( | ||||||||||
| spec: ApiEndpointSpec, | ||||||||||
| args: Record<string, unknown>, | ||||||||||
| stdin: Readable | ||||||||||
| ) => { | ||||||||||
| if (spec.bodyMode === 'none') { | ||||||||||
| validateBodyMode(spec.bodyMode, args) | ||||||||||
| return undefined | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (spec.bodyMode === 'file') { | ||||||||||
| validateBodyMode('none', args) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Suggested change
|
||||||||||
| if (typeof args.file !== 'string' || args.file.length === 0) { | ||||||||||
| throw new ApiValidationError('--file is required for this command.') | ||||||||||
| } | ||||||||||
| return { filePath: args.file } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| validateBodyMode('json', args) | ||||||||||
| const rawBody = await loadJsonBodyInput(args, stdin) | ||||||||||
| const normalizedBody = spec.bodyAdapter ? spec.bodyAdapter(rawBody) : rawBody | ||||||||||
| const body = validateWithSchema('Request body', spec.bodySchema, normalizedBody) | ||||||||||
| return { jsonBody: body } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export const buildApiRequestFromArgs = async ( | ||||||||||
| spec: ApiEndpointSpec, | ||||||||||
| args: Record<string, unknown>, | ||||||||||
| stdin: Readable = process.stdin | ||||||||||
| ) => { | ||||||||||
| const pathParams = collectPathParams(spec, args) | ||||||||||
| const query = collectValidatedQuery(spec, args) | ||||||||||
| const body = await collectBody(spec, args, stdin) | ||||||||||
|
|
||||||||||
| return { | ||||||||||
| method: spec.method, | ||||||||||
| pathname: buildPathname(spec.pathTemplate, pathParams), | ||||||||||
| query: serializeQueryObject(query), | ||||||||||
| ...body, | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export const executeApiCommand = async ( | ||||||||||
| spec: ApiEndpointSpec, | ||||||||||
| args: Record<string, unknown>, | ||||||||||
| options?: ExecuteApiCommandOptions | ||||||||||
| ) => { | ||||||||||
| loadEnvs() | ||||||||||
| const request = await buildApiRequestFromArgs(spec, args, options?.stdin) | ||||||||||
| const baseUrl = options?.baseUrl ?? process.env.QAS_URL | ||||||||||
| const apiKey = options?.apiKey ?? process.env.QAS_TOKEN | ||||||||||
|
|
||||||||||
| if (!baseUrl || !apiKey) { | ||||||||||
| throw new ApiValidationError('QAS_URL and QAS_TOKEN are required.') | ||||||||||
| } | ||||||||||
|
|
||||||||||
| try { | ||||||||||
| new URL(baseUrl) | ||||||||||
| } catch { | ||||||||||
| throw new ApiValidationError( | ||||||||||
| 'QAS_URL must be a valid absolute URL, for example https://qas.eu1.qasphere.com' | ||||||||||
| ) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const response = await executePublicApiRequest(baseUrl, apiKey, request) | ||||||||||
| return response | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
buildErrorFromResponsefunction specifically re-throws JSON parsing errors only if the message is not 'Unexpected end of JSON input'. This approach might inadvertently suppress other valid JSON parsing errors, making it harder to debug issues where the API returns malformed JSON for reasons other than an incomplete payload. It's generally better to either re-throw all parsing errors with additional context or handle them more explicitly.