From 4b09262de9fbcfae05fdc7c3211c27cee7ffc8c1 Mon Sep 17 00:00:00 2001 From: Duane Irvin Date: Tue, 5 May 2026 12:39:20 +0200 Subject: [PATCH] Simplify gxgames api calls Using custom code templates for swagger instead of the default ones. They are still quite similar to the defaults, so there is still plenty of room for improvement. --- .prettierignore | 1 + package.json | 2 +- .../commands/gxgames/api/gen.ts | 9 +- .../gxgames/api/{ => generated}/Gamedev.ts | 478 +++++++++--------- .../api/{ => generated}/data-contracts.ts | 0 .../api/{ => generated}/error-codes.ts | 63 +-- .../api/{ => generated}/http-client.ts | 33 +- src/commands/gxgames/api/helpers.ts | 43 -- .../gxgames/api/templates/http-client.ejs | 248 +++++++++ .../gxgames/api/templates/procedure-call.ejs | 96 ++++ src/commands/gxgames/auth/error-page.html | 0 src/commands/gxgames/auth/success-page.html | 0 src/commands/gxgames/client.ts | 2 +- src/commands/gxgames/link-impl.ts | 29 +- src/commands/gxgames/meta-impl.ts | 37 +- src/commands/gxgames/publish-impl.ts | 3 +- src/commands/gxgames/upload-impl.ts | 23 +- 17 files changed, 686 insertions(+), 381 deletions(-) rename scripts/gen-ggbe-api.ts => src/commands/gxgames/api/gen.ts (93%) rename src/commands/gxgames/api/{ => generated}/Gamedev.ts (58%) rename src/commands/gxgames/api/{ => generated}/data-contracts.ts (100%) rename src/commands/gxgames/api/{ => generated}/error-codes.ts (54%) rename src/commands/gxgames/api/{ => generated}/http-client.ts (90%) delete mode 100644 src/commands/gxgames/api/helpers.ts create mode 100644 src/commands/gxgames/api/templates/http-client.ejs create mode 100644 src/commands/gxgames/api/templates/procedure-call.ejs create mode 100644 src/commands/gxgames/auth/error-page.html create mode 100644 src/commands/gxgames/auth/success-page.html 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);