diff --git a/.prettierignore b/.prettierignore index 15dd970..f77ae84 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ pnpm-lock.yaml test README.md +**/generated diff --git a/package.json b/package.json index 3f7b2fc..8ea1952 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "fix:eslint": "eslint --fix --max-warnings 0", "fix:prettier": "prettier --write . --ignore-path .gitignore --ignore-path .prettierignore", "fix": "run-p fix:*", - "gen:ggbe-api": "node ./scripts/gen-ggbe-api.ts" + "gen:ggbe-api": "node ./src/commands/gxgames/api/gen.ts" }, "devDependencies": { "@clack/prompts": "^1.1.0", diff --git a/scripts/gen-ggbe-api.ts b/src/commands/gxgames/api/gen.ts similarity index 93% rename from scripts/gen-ggbe-api.ts rename to src/commands/gxgames/api/gen.ts index 6819e0f..95e7cec 100644 --- a/scripts/gen-ggbe-api.ts +++ b/src/commands/gxgames/api/gen.ts @@ -1,3 +1,5 @@ +/* eslint-disable */ +// @ts-nocheck import { generateApi } from "swagger-typescript-api"; import path from "node:path"; import { writeFile } from "node:fs/promises"; @@ -149,8 +151,8 @@ for (const path of Object.keys(schema.paths)) { } writeFile( - path.resolve("./src/commands/gxgames/api", "error-codes.ts"), - `export const ApiErrorCodes = { + path.resolve("./src/commands/gxgames/api/generated", "error-codes.ts"), + `/* eslint-disable */\nexport const ApiErrorCodes = { ${Object.entries(errorCodes) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => ` ${key}: "${value}",\n`) @@ -160,7 +162,7 @@ ${Object.entries(errorCodes) await generateApi({ spec: schema, - output: path.resolve("./src/commands/gxgames/api"), + output: path.resolve("./src/commands/gxgames/api/generated"), fileName: "api.ts", httpClientType: "fetch", disableThrowOnError: true, @@ -171,4 +173,5 @@ await generateApi({ modular: true, sortRoutes: true, sortTypes: true, + templates: path.resolve("./src/commands/gxgames/api/templates"), }); diff --git a/src/commands/gxgames/api/Gamedev.ts b/src/commands/gxgames/api/generated/Gamedev.ts similarity index 58% rename from src/commands/gxgames/api/Gamedev.ts rename to src/commands/gxgames/api/generated/Gamedev.ts index cf69070..c7c4943 100644 --- a/src/commands/gxgames/api/Gamedev.ts +++ b/src/commands/gxgames/api/generated/Gamedev.ts @@ -50,26 +50,28 @@ export class Gamedev< * @secure */ createGame = (data: GameDevCreateGameRequest, params: RequestParams = {}) => - this.request< - { - data: GameDevGameResponse; - errors: null; - }, - { - data: null; - errors: { - code: CreateGameCodeEnum; - }[]; - } - >({ - path: `/gamedev/games`, - method: "POST", - body: data, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevGameResponse; + errors: null; + }, + { + data: null; + errors: { + code: CreateGameCodeEnum; + }[]; + } + >({ + path: `/gamedev/games`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + ); /** * No description * @@ -80,23 +82,25 @@ export class Gamedev< * @secure */ deleteCover = (gameId: string, coverId: string, params: RequestParams = {}) => - this.request< - { - errors: null; - }, - { - data: null; - errors: { - code: DeleteCoverCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}/covers/${coverId}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + errors: null; + }, + { + data: null; + errors: { + code: DeleteCoverCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}/covers/${coverId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -111,23 +115,25 @@ export class Gamedev< graphicId: string, params: RequestParams = {}, ) => - this.request< - { - errors: null; - }, - { - data: null; - errors: { - code: DeleteGraphicCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}/graphics/${graphicId}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + errors: null; + }, + { + data: null; + errors: { + code: DeleteGraphicCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}/graphics/${graphicId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -138,24 +144,26 @@ export class Gamedev< * @secure */ getGameDetails = (gameId: string, params: RequestParams = {}) => - this.request< - { - data: GameDevGameDetailsResponse; - errors: null; - }, - { - data: null; - errors: { - code: GetGameDetailsCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}`, - method: "GET", - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevGameDetailsResponse; + errors: null; + }, + { + data: null; + errors: { + code: GetGameDetailsCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -166,24 +174,26 @@ export class Gamedev< * @secure */ getProfile = (params: RequestParams = {}) => - this.request< - { - data: GameDevUserResponse; - errors: null; - }, - { - data: null; - errors: { - code: GetProfileCodeEnum; - }[]; - } - >({ - path: `/gamedev/profile`, - method: "GET", - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevUserResponse; + errors: null; + }, + { + data: null; + errors: { + code: GetProfileCodeEnum; + }[]; + } + >({ + path: `/gamedev/profile`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -224,25 +234,27 @@ export class Gamedev< }, params: RequestParams = {}, ) => - this.request< - { - data: GameDevGamesResponse; - errors: null; - }, - { - data: null; - errors: { - code: GetUserGamesCodeEnum; - }[]; - } - >({ - path: `/gamedev/games`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevGamesResponse; + errors: null; + }, + { + data: null; + errors: { + code: GetUserGamesCodeEnum; + }[]; + } + >({ + path: `/gamedev/games`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -271,25 +283,27 @@ export class Gamedev< }, params: RequestParams = {}, ) => - this.request< - { - data: GameDevStudiosResponse; - errors: null; - }, - { - data: null; - errors: { - code: GetUserStudiosCodeEnum; - }[]; - } - >({ - path: `/gamedev/studios`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevStudiosResponse; + errors: null; + }, + { + data: null; + errors: { + code: GetUserStudiosCodeEnum; + }[]; + } + >({ + path: `/gamedev/studios`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -300,23 +314,25 @@ export class Gamedev< * @secure */ publishGame = (gameId: string, params: RequestParams = {}) => - this.request< - { - errors: null; - }, - { - data: null; - errors: { - code: PublishGameCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}/publish`, - method: "POST", - secure: true, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + errors: null; + }, + { + data: null; + errors: { + code: PublishGameCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}/publish`, + method: "POST", + secure: true, + format: "json", + ...params, + }), + ); /** * No description * @@ -331,26 +347,28 @@ export class Gamedev< data: GameDevUpdateGameRequest, params: RequestParams = {}, ) => - this.request< - { - data: GameDevGameDetailsResponse; - errors: null; - }, - { - data: null; - errors: { - code: UpdateGameCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}`, - method: "PATCH", - body: data, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevGameDetailsResponse; + errors: null; + }, + { + data: null; + errors: { + code: UpdateGameCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}`, + method: "PATCH", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + ); /** * No description * @@ -383,27 +401,29 @@ export class Gamedev< }, params: RequestParams = {}, ) => - this.request< - { - data: GameDevCoverResponse; - errors: null; - }, - { - data: null; - errors: { - code: UploadCoverCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}/covers`, - method: "POST", - query: query, - body: data, - secure: true, - type: ContentType.FormData, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevCoverResponse; + errors: null; + }, + { + data: null; + errors: { + code: UploadCoverCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}/covers`, + method: "POST", + query: query, + body: data, + secure: true, + type: ContentType.FormData, + format: "json", + ...params, + }), + ); /** * No description * @@ -428,27 +448,29 @@ export class Gamedev< }, params: RequestParams = {}, ) => - this.request< - { - data: GameDevGameResponse; - errors: null; - }, - { - data: null; - errors: { - code: UploadGameBundleCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}/bundles`, - method: "POST", - query: query, - body: data, - secure: true, - type: ContentType.FormData, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevGameResponse; + errors: null; + }, + { + data: null; + errors: { + code: UploadGameBundleCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}/bundles`, + method: "POST", + query: query, + body: data, + secure: true, + type: ContentType.FormData, + format: "json", + ...params, + }), + ); /** * No description * @@ -468,24 +490,26 @@ export class Gamedev< }, params: RequestParams = {}, ) => - this.request< - { - data: GameDevGraphicResponse; - errors: null; - }, - { - data: null; - errors: { - code: UploadGraphicCodeEnum; - }[]; - } - >({ - path: `/gamedev/games/${gameId}/graphics`, - method: "POST", - body: data, - secure: true, - type: ContentType.FormData, - format: "json", - ...params, - }); + this.unwrap( + this.request< + { + data: GameDevGraphicResponse; + errors: null; + }, + { + data: null; + errors: { + code: UploadGraphicCodeEnum; + }[]; + } + >({ + path: `/gamedev/games/${gameId}/graphics`, + method: "POST", + body: data, + secure: true, + type: ContentType.FormData, + format: "json", + ...params, + }), + ); } diff --git a/src/commands/gxgames/api/data-contracts.ts b/src/commands/gxgames/api/generated/data-contracts.ts similarity index 100% rename from src/commands/gxgames/api/data-contracts.ts rename to src/commands/gxgames/api/generated/data-contracts.ts diff --git a/src/commands/gxgames/api/error-codes.ts b/src/commands/gxgames/api/generated/error-codes.ts similarity index 54% rename from src/commands/gxgames/api/error-codes.ts rename to src/commands/gxgames/api/generated/error-codes.ts index d1ac148..a8c1e37 100644 --- a/src/commands/gxgames/api/error-codes.ts +++ b/src/commands/gxgames/api/generated/error-codes.ts @@ -1,35 +1,17 @@ -/** - * Copyright 2026, Opera Norway AS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at: - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +/* eslint-disable */ export const ApiErrorCodes = { age_rating_not_set: "age rating needed to publish the public track", asset_not_found: "asset not found", aws_upload_error: "failed uploading the file to S3", - breaking_published_game: - "game is published and this operation would break requirements for publication", + breaking_published_game: "game is published and this operation would break requirements for publication", bundle_must_be_zip: "only zip files are allowed for game bundles", cover_not_found: "cover not found", game_access_denied: "game access denied", game_bundle_too_big: "game bundle exceeds the maximum size", - game_bundle_unsupported_charset: - "game bundle was encoded with an unsupported charset", + game_bundle_unsupported_charset: "game bundle was encoded with an unsupported charset", game_cover_not_found: "cover id not valid or not found for provided gameId", - game_creation_not_allowed: - "only the owner of the group may create a game for the group", - game_description_empty: - "short description needed to publish the public track", + game_creation_not_allowed: "only the owner of the group may create a game for the group", + game_description_empty: "short description needed to publish the public track", game_engine_not_found: "game engine not found", game_is_blocked: "game is currently blocked", game_name_empty: "a game name is required", @@ -46,42 +28,31 @@ export const ApiErrorCodes = { invalid_characters_in_name: "name contains one or more invalid characters", invalid_image: "image validation failed", invalid_request_payload: "validation error in payload request", - invalid_studio_ownership: - "the users studio has an invalid amount of owners (not 1)", + invalid_studio_ownership: "the users studio has an invalid amount of owners (not 1)", max_games_limit_reached: "the group has reached its max games limit", - max_graphics_limit_reached: - "the game has reached the maximum number of graphics per game limit", - missing_game_covers: - "must have a cover uploaded for all supported aspect ratios", + max_graphics_limit_reached: "the game has reached the maximum number of graphics per game limit", + missing_game_covers: "must have a cover uploaded for all supported aspect ratios", missing_index_file: "uploaded bundle is missing the file index.html", no_changes: "the request resulted in no changes", no_graphics_uploaded: "at least 1 graphic needed to publish the public track", - no_platforms_added: - "at least one platform needed to publish the public track", + no_platforms_added: "at least one platform needed to publish the public track", no_release_for_track: "no releases on the selected track", no_specific_studio_for_user: "The user doesn't have a specific studio", page_invalid: "invalid page parameter value; must be a valid decimal integer", page_less_than_0: "invalid page parameter value; must be 0 or higher", - page_size_invalid: - "invalid pageSize parameter value; must be a valid decimal integer", - page_size_less_than_1: - "invalid pageSize parameter value; must be 1 or higher", + page_size_invalid: "invalid pageSize parameter value; must be a valid decimal integer", + page_size_less_than_1: "invalid pageSize parameter value; must be 1 or higher", page_size_too_high: "the page size exceeds the maximum limit", - preview_required_for_videos: - "when uploading videos, a 'preview' image must be included as a thumbnail", - shared_array_buffer_is_not_supported: - "Shared Array buffer support is not provided", + preview_required_for_videos: "when uploading videos, a 'preview' image must be included as a thumbnail", + shared_array_buffer_is_not_supported: "Shared Array buffer support is not provided", short_description_too_long: "short description too long", sign_in_required: "the user must be signed in", sign_up_not_completed: "user has not completed the sign-up process", studio_access_denied: "studio access denied", studio_not_found: "studio id not valid", - template_metadata_required_for_template: - "games in the HH Store should have template metadata", - unsupported_graphic_format: - "the image format is not supported or could not be identified", - upload_custom_game_bundle_not_allowed: - "upload custom game bundle not allowed", + template_metadata_required_for_template: "games in the HH Store should have template metadata", + unsupported_graphic_format: "the image format is not supported or could not be identified", + upload_custom_game_bundle_not_allowed: "upload custom game bundle not allowed", version_number_too_low: "version number is lower than previous version", video_not_allowed_as_image: "not allowed to use video as image", -}; +} diff --git a/src/commands/gxgames/api/http-client.ts b/src/commands/gxgames/api/generated/http-client.ts similarity index 90% rename from src/commands/gxgames/api/http-client.ts rename to src/commands/gxgames/api/generated/http-client.ts index 479b30c..9521713 100644 --- a/src/commands/gxgames/api/http-client.ts +++ b/src/commands/gxgames/api/generated/http-client.ts @@ -10,6 +10,8 @@ * --------------------------------------------------------------- */ +import { ApiErrorCodes } from "./error-codes"; + export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; @@ -46,10 +48,8 @@ export interface ApiConfig { customFetch?: typeof fetch; } -export interface HttpResponse< - D extends unknown, - E extends unknown = unknown, -> extends Response { +export interface HttpResponse + extends Response { data: D; error: E; } @@ -264,4 +264,29 @@ export class HttpClient { return data; }); }; + + public unwrap = async < + TData extends { errors: null; data?: unknown }, + TError extends { errors: { code: keyof typeof ApiErrorCodes }[] }, + >( + response: Promise>, + ) => { + const r = await response; + if (r.error) { + return { + success: false as const, + errors: r.error.errors.map(({ code }) => ({ + code, + description: ApiErrorCodes[code], + })) as { + code: TError["errors"][number]["code"]; + description: string; + }[], + }; + } + return { + success: true as const, + data: r.data.data as TData["data"], + }; + }; } diff --git a/src/commands/gxgames/api/helpers.ts b/src/commands/gxgames/api/helpers.ts deleted file mode 100644 index 44f484d..0000000 --- a/src/commands/gxgames/api/helpers.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright 2026, Opera Norway AS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at: - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ApiErrorCodes } from "./error-codes"; -import type { HttpResponse } from "./http-client"; - -export const unwrapResponse = async < - TData extends { errors: null; data?: unknown }, - TError extends { errors: { code: keyof typeof ApiErrorCodes }[] }, ->( - response: HttpResponse | Promise>, -) => { - response = await response; - if (response.error) { - return { - success: false, - errors: response.error.errors.map(({ code }) => ({ - code, - description: ApiErrorCodes[code], - })) as { - code: TError["errors"][number]["code"]; - description: string; - }[], - } as const; - } - - return { - success: true, - data: response.data.data as TData["data"], - } as const; -}; diff --git a/src/commands/gxgames/api/templates/http-client.ejs b/src/commands/gxgames/api/templates/http-client.ejs new file mode 100644 index 0000000..ab95c4f --- /dev/null +++ b/src/commands/gxgames/api/templates/http-client.ejs @@ -0,0 +1,248 @@ +<% +const { apiConfig, generateResponses, config } = it; +%> + +import { ApiErrorCodes } from "./error-codes"; + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit + + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: (securityData: SecurityDataType | null) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = "<%~ apiConfig.baseUrl %>"; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: 'same-origin', + headers: {}, + redirect: 'follow', + referrerPolicy: 'no-referrer', + } + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + } + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [ContentType.JsonApi]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [ContentType.Text]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob ? + property : + typeof property === "object" && property !== null ? + JSON.stringify(property) : + `${property}` + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + } + + protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + } + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken) + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + } + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && this.securityWorker && await this.securityWorker(this.securityData)) || {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), + }, + signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null, + body: typeof body === "undefined" || body === null ? null : payloadFormatter(body), + } + ).then(async (response) => { + const r = response as HttpResponse; + r.data = (null as unknown) as T; + r.error = (null as unknown) as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat ? r : await responseToParse[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + return data; + }); + }; + + public unwrap = async < + TData extends { errors: null; data?: unknown }, + TError extends { errors: { code: keyof typeof ApiErrorCodes }[] }, + >( + response: Promise>, + ) => { + const r = await response; + if (r.error) { + return { + success: false as const, + errors: r.error.errors.map(({ code }) => ({ + code, + description: ApiErrorCodes[code], + })) as { + code: TError["errors"][number]["code"]; + description: string; + }[], + }; + } + return { + success: true as const, + data: r.data.data as TData["data"], + }; + }; +} diff --git a/src/commands/gxgames/api/templates/procedure-call.ejs b/src/commands/gxgames/api/templates/procedure-call.ejs new file mode 100644 index 0000000..2ad98fc --- /dev/null +++ b/src/commands/gxgames/api/templates/procedure-call.ejs @@ -0,0 +1,96 @@ +<% +const { utils, route, config } = it; +const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route; +const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils; +const { parameters, path, method, payload, query, formData, security, requestParams } = route.request; +const { type, errorType, contentTypes } = route.response; +const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants; +const routeDocs = includeFile("@base/route-docs", { config, route, utils }); +const queryName = (query && query.name) || "query"; +const pathParams = _.values(parameters); +const pathParamsNames = _.map(pathParams, "name"); + +const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH; + +const requestConfigParam = { + name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES), + optional: true, + type: "RequestParams", + defaultValue: "{}", +} + +const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`; + +const extractedRequestParamsName = pathParams.length + ? query + ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` + : `{ ${_.join(pathParamsNames, ", ")} }` + : queryName; + +const rawWrapperArgs = config.extractRequestParams ? + _.compact([ + requestParams && { + name: extractedRequestParamsName, + optional: false, + type: getInlineParseContent(requestParams), + }, + ...(!requestParams ? pathParams : []), + payload, + requestConfigParam, + ]) : + _.compact([ + ...pathParams, + query, + payload, + requestConfigParam, + ]) + +const wrapperArgs = _ + // Sort by optionality + .sortBy(rawWrapperArgs, [o => o.optional]) + .map(argToTmpl) + .join(', ') + +// RequestParams["type"] +const requestContentKind = { + "JSON": "ContentType.Json", + "JSON_API": "ContentType.JsonApi", + "URL_ENCODED": "ContentType.UrlEncoded", + "FORM_DATA": "ContentType.FormData", + "TEXT": "ContentType.Text", +} +// RequestParams["format"] +const responseContentKind = { + "JSON": '"json"', + "IMAGE": '"blob"', + "FORM_DATA": isFetchTemplate ? '"formData"' : '"document"' +} + +const bodyTmpl = _.get(payload, "name") || null; +const queryTmpl = (query != null && queryName) || null; +const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null; +const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null; +const securityTmpl = security ? 'true' : null; + +const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); + +%> +/** +<%~ routeDocs.description %> + + *<% /* Here you can add some other JSDoc tags */ %> + +<%~ routeDocs.lines %> + + */ +<% if (isValidIdentifier(route.routeName.usage)) { %><%~ route.routeName.usage %><% } else { %>"<%~ route.routeName.usage %>"<% } %> = (<%~ wrapperArgs %>) => + this.unwrap(<%~ config.singleHttpClient ? 'this.http.request' : 'this.request' %><<%~ type %>, <%~ errorType %>>({ + path: `<%~ path %>`, + method: '<%~ _.upperCase(method) %>', + <%~ queryTmpl ? `query: ${queryTmpl},` : '' %> + <%~ bodyTmpl ? `body: ${bodyTmpl},` : '' %> + <%~ securityTmpl ? `secure: ${securityTmpl},` : '' %> + <%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %> + <%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %> + ...<%~ _.get(requestConfigParam, "name") %>, + })) diff --git a/src/commands/gxgames/auth/error-page.html b/src/commands/gxgames/auth/error-page.html new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/gxgames/auth/success-page.html b/src/commands/gxgames/auth/success-page.html new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/gxgames/client.ts b/src/commands/gxgames/client.ts index 8bb55ac..12194d0 100644 --- a/src/commands/gxgames/client.ts +++ b/src/commands/gxgames/client.ts @@ -15,7 +15,7 @@ */ import type { Context } from "~/context"; -import { Gamedev } from "./api/Gamedev"; +import { Gamedev } from "./api/generated/Gamedev"; import type { AuthManager } from "./auth"; import { GG_API } from "./config"; diff --git a/src/commands/gxgames/link-impl.ts b/src/commands/gxgames/link-impl.ts index 635ece3..399b2d9 100644 --- a/src/commands/gxgames/link-impl.ts +++ b/src/commands/gxgames/link-impl.ts @@ -19,7 +19,6 @@ import type { Context } from "~/context"; import { KnownError } from "~/error"; import { createAuthManager } from "./auth"; import { getApiClient } from "./client"; -import { unwrapResponse } from "./api/helpers"; import { writeLink } from "./link"; import { Cache } from "~/cache"; @@ -34,9 +33,7 @@ export default async function ( const api = getApiClient(this, createAuthManager(this)); if (!studioId) { - const studiosRes = await unwrapResponse( - api.getUserStudios({ pageSize: 999 }), - ); + const studiosRes = await api.getUserStudios({ pageSize: 999 }); if (!studiosRes.success) { throw new KnownError(studiosRes.errors); } @@ -55,13 +52,11 @@ export default async function ( } if (!gameId) { - const gamesRes = await unwrapResponse( - api.getUserGames({ - studioId: [studioId], - pageSize: 999, - gameEngine: ["game-maker"], - }), - ); + const gamesRes = await api.getUserGames({ + studioId: [studioId], + pageSize: 999, + gameEngine: ["game-maker"], + }); if (!gamesRes.success) { throw new KnownError(gamesRes.errors); } @@ -86,13 +81,11 @@ export default async function ( return process.exit(0); } const createLog = this.makeTaskLogger("Creating game"); - const res = await unwrapResponse( - api.createGame({ - name: gameName, - studioId, - gameEngine: "game-maker", - }), - ); + const res = await api.createGame({ + name: gameName, + studioId, + gameEngine: "game-maker", + }); if (!res.success) { throw new KnownError(res.errors); } diff --git a/src/commands/gxgames/meta-impl.ts b/src/commands/gxgames/meta-impl.ts index d6f6f88..c993d50 100644 --- a/src/commands/gxgames/meta-impl.ts +++ b/src/commands/gxgames/meta-impl.ts @@ -20,12 +20,11 @@ import { KnownError } from "~/error"; import { readLink } from "./link"; import { createAuthManager } from "./auth"; import { getApiClient } from "./client"; -import { unwrapResponse } from "./api/helpers"; import { Cache } from "~/cache"; import type { GameDevUpdateGameRequestAgeRatingEnum, GameDevUpdateGameRequestPlatformsEnum, -} from "./api/data-contracts"; +} from "./api/generated/data-contracts"; const AGE_RATING_OPTIONS: { value: GameDevUpdateGameRequestAgeRatingEnum; @@ -62,7 +61,7 @@ export default async function (this: Context, flags: MetaFlags): Promise { const link = await readLink(this, cache); const api = getApiClient(this, createAuthManager(this)); - const gameRes = await unwrapResponse(api.getGameDetails(link.gameId)); + const gameRes = await api.getGameDetails(link.gameId); if (!gameRes.success) { throw new KnownError(gameRes.errors); } @@ -135,14 +134,12 @@ export default async function (this: Context, flags: MetaFlags): Promise { } const updateLog = this.makeTaskLogger("Updating metadata"); - const updateRes = await unwrapResponse( - api.updateGame(link.gameId, { - title, - shortDescription: description, - ageRating, - platforms, - }), - ); + const updateRes = await api.updateGame(link.gameId, { + title, + shortDescription: description, + ageRating, + platforms, + }); if (!updateRes.success) { updateLog.error("Failed"); throw new KnownError(updateRes.errors); @@ -168,12 +165,10 @@ export default async function (this: Context, flags: MetaFlags): Promise { if (coverPath) { const coverLog = this.makeTaskLogger("Uploading cover"); const fileBuffer = await this.fs.readFile(coverPath); - const coverRes = await unwrapResponse( - api.uploadCover( - link.gameId, - { aspectRatio: "16:9", coverType: "IMAGE" }, - { file: new File([fileBuffer], this.path.basename(coverPath)) }, - ), + const coverRes = await api.uploadCover( + link.gameId, + { aspectRatio: "16:9", coverType: "IMAGE" }, + { file: new File([fileBuffer], this.path.basename(coverPath)) }, ); if (!coverRes.success) { coverLog.error("Failed"); @@ -201,11 +196,9 @@ export default async function (this: Context, flags: MetaFlags): Promise { if (graphicPath) { const graphicLog = this.makeTaskLogger("Uploading graphic"); const fileBuffer = await this.fs.readFile(graphicPath); - const graphicRes = await unwrapResponse( - api.uploadGraphic(link.gameId, { - file: new File([fileBuffer], this.path.basename(graphicPath)), - }), - ); + const graphicRes = await api.uploadGraphic(link.gameId, { + file: new File([fileBuffer], this.path.basename(graphicPath)), + }); if (!graphicRes.success) { graphicLog.error("Failed"); throw new KnownError(graphicRes.errors); diff --git a/src/commands/gxgames/publish-impl.ts b/src/commands/gxgames/publish-impl.ts index 5784fdb..70a2afe 100644 --- a/src/commands/gxgames/publish-impl.ts +++ b/src/commands/gxgames/publish-impl.ts @@ -20,7 +20,6 @@ import { KnownError } from "~/error"; import { readLink } from "./link"; import { createAuthManager } from "./auth"; import { getApiClient } from "./client"; -import { unwrapResponse } from "./api/helpers"; import { Cache } from "~/cache"; export default async function ( @@ -32,7 +31,7 @@ export default async function ( const api = getApiClient(this, createAuthManager(this)); const publishLog = this.makeTaskLogger("Publishing game"); - const res = await unwrapResponse(api.publishGame(link.gameId)); + const res = await api.publishGame(link.gameId); if (!res.success) { publishLog.error("Publish failed"); diff --git a/src/commands/gxgames/upload-impl.ts b/src/commands/gxgames/upload-impl.ts index 0340cc4..965e0db 100644 --- a/src/commands/gxgames/upload-impl.ts +++ b/src/commands/gxgames/upload-impl.ts @@ -18,7 +18,6 @@ import type { Context } from "~/context"; import * as p from "@clack/prompts"; import { getApiClient } from "./client"; import { createAuthManager } from "./auth"; -import { unwrapResponse } from "./api/helpers"; import { KnownError } from "~/error"; import { readLink } from "./link"; import { Cache } from "~/cache"; @@ -33,13 +32,11 @@ export default async function ( const api = getApiClient(this, createAuthManager(this)); - const gamesRes = await unwrapResponse( - api.getUserGames({ - studioId: [link.studioId], - pageSize: 999, - gameEngine: ["game-maker"], - }), - ); + const gamesRes = await api.getUserGames({ + studioId: [link.studioId], + pageSize: 999, + gameEngine: ["game-maker"], + }); if (!gamesRes.success) { throw new KnownError(gamesRes.errors); } @@ -65,12 +62,10 @@ export default async function ( const uploadLog = this.makeTaskLogger("Uploading bundle"); const fileBuffer = await this.fs.readFile(file); - const res = await unwrapResponse( - api.uploadGameBundle( - link.gameId, - { version }, - { file: new File([fileBuffer], this.path.basename(file)) }, - ), + const res = await api.uploadGameBundle( + link.gameId, + { version }, + { file: new File([fileBuffer], this.path.basename(file)) }, ); if (!res.success) { throw new KnownError(res.errors);