diff --git a/back/package.json b/back/package.json index 1216efcf..eee46b56 100644 --- a/back/package.json +++ b/back/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/thecodingmachine/workadventure#readme", "dependencies": { + "@anatine/zod-openapi": "^1.3.0", "@workadventure/tiled-map-type-guard": "^1.0.3", "axios": "^0.21.2", "busboy": "^0.3.1", @@ -50,6 +51,7 @@ "ipaddr.js": "^2.0.1", "jsonwebtoken": "^8.5.1", "mkdirp": "^1.0.4", + "openapi3-ts": "^2.0.2", "prom-client": "^12.0.0", "query-string": "^6.13.3", "redis": "^3.1.2", diff --git a/back/yarn.lock b/back/yarn.lock index d23fc29b..615d7794 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@anatine/zod-openapi@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@anatine/zod-openapi/-/zod-openapi-1.3.0.tgz#b5b38c3d821b79674226aa7b327c88c371860d0d" + integrity sha512-l54DypUdDsIq1Uwjv4ib9IBkTXMKZQLUj7qvdFL51EExC5LdSSqOlTOyaVVZZGYgWPKM7ZjGklhdoknLz4EC+w== + dependencies: + ts-deepmerge "^1.1.0" + validator "^13.7.0" + "@babel/code-frame@^7.0.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431" @@ -1563,6 +1571,13 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +openapi3-ts@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz#a200dd838bf24c9086c8eedcfeb380b7eb31e82a" + integrity sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw== + dependencies: + yaml "^1.10.2" + optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" @@ -2042,6 +2057,11 @@ tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== +ts-deepmerge@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ts-deepmerge/-/ts-deepmerge-1.1.0.tgz#4236ae102199affe2e77690dcf198a420160eef2" + integrity sha512-VvwaV/6RyYMwT9d8dClmfHIsG2PCdm6WY430QKOIbPRR50Y/1Q2ilp4i2XEZeHFcNqfaYnAQzpyUC6XA0AqqBg== + ts-node-dev@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.1.8.tgz#95520d8ab9d45fffa854d6668e2f8f9286241066" @@ -2153,6 +2173,11 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +validator@^13.7.0: + version "13.7.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" + integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -2236,7 +2261,7 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^1.10.0: +yaml@^1.10.0, yaml@^1.10.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== diff --git a/front/package.json b/front/package.json index e5ccede2..e6b94010 100644 --- a/front/package.json +++ b/front/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@16bits/nes.css": "^2.3.2", + "@anatine/zod-openapi": "^1.3.0", "@fontsource/press-start-2p": "^4.3.0", "@joeattardi/emoji-button": "^4.6.2", "@types/simple-peer": "^9.11.1", @@ -46,6 +47,7 @@ "easystarjs": "^0.4.4", "fast-deep-equal": "^3.1.3", "google-protobuf": "^3.13.0", + "openapi3-ts": "^2.0.2", "phaser": "3.55.1", "phaser-animated-tiles": "workadventure/phaser-animated-tiles#da68bbededd605925621dd4f03bd27e69284b254", "phaser3-rex-plugins": "^1.1.42", diff --git a/front/yarn.lock b/front/yarn.lock index cfd5942f..4729cb7a 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -7,6 +7,14 @@ resolved "https://registry.yarnpkg.com/@16bits/nes.css/-/nes.css-2.3.2.tgz#e69db834119b33ae8d3cb044f106a07a17cadd6f" integrity sha512-nEM5PIth+Bab5JSOa4uUR+PMNUsNTYxA55oVlG3gXI/4LoYtWS767Uv9Pu/KCbHXVvnIjt4ZXt13kZw3083qTw== +"@anatine/zod-openapi@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@anatine/zod-openapi/-/zod-openapi-1.3.0.tgz#b5b38c3d821b79674226aa7b327c88c371860d0d" + integrity sha512-l54DypUdDsIq1Uwjv4ib9IBkTXMKZQLUj7qvdFL51EExC5LdSSqOlTOyaVVZZGYgWPKM7ZjGklhdoknLz4EC+w== + dependencies: + ts-deepmerge "^1.1.0" + validator "^13.7.0" + "@babel/runtime@^7.14.0": version "7.14.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6" @@ -2055,6 +2063,13 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +openapi3-ts@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz#a200dd838bf24c9086c8eedcfeb380b7eb31e82a" + integrity sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw== + dependencies: + yaml "^1.10.2" + optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" @@ -2875,6 +2890,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +ts-deepmerge@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ts-deepmerge/-/ts-deepmerge-1.1.0.tgz#4236ae102199affe2e77690dcf198a420160eef2" + integrity sha512-VvwaV/6RyYMwT9d8dClmfHIsG2PCdm6WY430QKOIbPRR50Y/1Q2ilp4i2XEZeHFcNqfaYnAQzpyUC6XA0AqqBg== + ts-deferred@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/ts-deferred/-/ts-deferred-1.0.4.tgz#58145ebaeef5b8f2a290b8cec3d060839f9489c7" @@ -3066,6 +3086,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validator@^13.7.0: + version "13.7.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" + integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw== + vite-plugin-rewrite-all@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/vite-plugin-rewrite-all/-/vite-plugin-rewrite-all-0.1.2.tgz#312bbcd76c700ceac5153bfc5ad7e3e3e4bc9606" diff --git a/messages/JsonMessages/AdminApiData.ts b/messages/JsonMessages/AdminApiData.ts index 5c994a11..a0469f5a 100644 --- a/messages/JsonMessages/AdminApiData.ts +++ b/messages/JsonMessages/AdminApiData.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import {extendApi} from "@anatine/zod-openapi"; /* * WARNING! The original file is in /messages/JsonMessages. @@ -6,10 +7,11 @@ import { z } from "zod"; */ export const isAdminApiData = z.object({ - userUuid: z.string(), - email: z.nullable(z.string()), - roomUrl: z.string(), - mapUrlStart: z.string(), + // @ts-ignore + userUuid: extendApi(z.string(), {example: '998ce839-3dea-4698-8b41-ebbdf7688ad9'}), + email: extendApi(z.nullable(z.string()), {description: 'The email of the current user.', example: 'example@workadventu.re'}), + roomUrl: extendApi(z.string(), {example: '/@/teamSlug/worldSlug/roomSlug'}), + mapUrlStart: extendApi(z.string(), {description: 'The full URL to the JSON map file', example: 'https://myuser.github.io/myrepo/map.json'}), messages: z.optional(z.array(z.unknown())), }); diff --git a/messages/JsonMessages/ErrorApiData.ts b/messages/JsonMessages/ErrorApiData.ts index ae8f48fa..9dc8dbbe 100644 --- a/messages/JsonMessages/ErrorApiData.ts +++ b/messages/JsonMessages/ErrorApiData.ts @@ -1,46 +1,69 @@ import { z } from "zod"; +import {extendApi} from "@anatine/zod-openapi"; /* * WARNING! The original file is in /messages/JsonMessages. * All other files are automatically copied from this file on container startup / build */ -export const isErrorApiErrorData = z.object({ +export const isErrorApiErrorData = extendApi( // @ts-ignore - type: z.literal("error"), - code: z.string(), - title: z.string(), - subtitle: z.string(), - details: z.string(), - image: z.string(), -}); + z.object({ + type: z.literal("error"), + code: extendApi(z.string(), {description: 'The system code of an error, it must be in SCREAMING_SNAKE_CASE.', example: 'ROOM_NOT_FOUND'}), + title: extendApi(z.string(), {description: "Big title displayed on the error screen.", example: "ERROR"}), + subtitle: extendApi(z.string(), {description: "Subtitle displayed to let the user know what is the main subject of the error.", example: "The room was not found."}), + details: extendApi(z.string(), {description: "Some others details on what the user can do if he don't understand the error.", example: "If you would like more information, you can contact the administrator or us at example@workadventu.re."}), + image: extendApi(z.string(), {description: "The URL of the image displayed just under the logo in the error screen.", example: 'https://example.com/error.png'}), + }), + { + description: 'This is an error that can be returned by the API, its type must be equal to "error".\n If such an error is caught, an error screen will be displayed.', + } +); -export const isErrorApiRetryData = z.object({ - type: z.literal("retry"), - code: z.string(), - title: z.string(), - subtitle: z.string(), - details: z.string(), - image: z.string(), - buttonTitle: z.optional(z.nullable(z.string())), - timeToRetry: z.number(), - canRetryManual: z.boolean(), -}); +export const isErrorApiRetryData = extendApi( + z.object({ + type: z.literal("retry"), + code: extendApi(z.string(), {description: 'The system code of an error, it must be in SCREAMING_SNAKE_CASE. \n It will not be displayed to the user.', example: 'WORLD_FULL'}), + title: extendApi(z.string(), {description: "Big title displayed on the error screen.", example: "ERROR"}), + subtitle: extendApi(z.string(), {description: "Subtitle displayed to let the user know what is the main subject of the error.", example: "Too successful, your WorkAdventure world is full!"}), + details: extendApi(z.string(), {description: "Some others details on what the user can do if he don't understand the error.", example: "New automatic attempt in 30 seconds"}), + image: extendApi(z.string(), {description: "The URL of the image displayed just under the logo in the waiting screen.", example: 'https://example.com/wait.png'}), + buttonTitle: extendApi(z.optional(z.nullable(z.string())), {description: "If this is not defined the button and the parameter canRetryManual is set to true, the button will be not displayed at all.", example: "Retry"}), + timeToRetry: extendApi(z.number(), {description: "This is the time (in millisecond) between the next auto refresh of the page.", example: 30_000}), + canRetryManual: extendApi(z.boolean(), {description: "This boolean show or hide the button to let the user refresh manually the current page.", example: true}), + }), + { + description: 'This is an error that can be returned by the API, its type must be equal to "retry".\n' + + 'If such an error is caught, a waiting screen will be displayed.', + } +); -export const isErrorApiRedirectData = z.object({ - type: z.literal("redirect"), - urlToRedirect: z.string(), -}); +export const isErrorApiRedirectData = extendApi( + z.object({ + type: z.literal("redirect"), + urlToRedirect: extendApi(z.string(), {description: 'A URL specified to redirect the user onto it directly', example: '/contact-us'}), + }), + { + description: 'This is an error that can be returned by the API, its type must be equal to "redirect".\n' + + 'If such an error is caught, the user will be automatically redirected to urlToRedirect.', + } +); -export const isErrorApiUnauthorizedData = z.object({ - type: z.literal("unauthorized"), - code: z.string(), - title: z.string(), - subtitle: z.string(), - details: z.string(), - image: z.string(), - buttonTitle: z.optional(z.nullable(z.string())), -}); +export const isErrorApiUnauthorizedData = extendApi( + z.object({ + type: z.literal("unauthorized"), + code: extendApi(z.string(), {description: "This is the system code of an error, it must be in SCREAMING_SNAKE_CASE.", example: "USER_ACCESS_FORBIDDEN"}), + title: extendApi(z.string(), {description: "Big title displayed on the error screen.", example: "ERROR"}), + subtitle: extendApi(z.string(), {description: "Subtitle displayed to let the user know what is the main subject of the error.", example: "You can't access this place."}), + details: extendApi(z.string(), {description: "Some others details on what the user can do if he don't understand the error.", example: "If you would like more information, you can contact the administrator or us at example@workadventu.re."}), + image: extendApi(z.string(), {description: "The URL of the image displayed just under the logo in the error screen.", example: 'https://example.com/error.png'}), + buttonTitle: extendApi(z.optional(z.nullable(z.string())), {description: "If this is not defined the button to logout will be not displayed.", example: "Log out"}), + }), + { + description: 'This is an error that can be returned by the API, its type must be equal to "unauthorized".\n' + + 'If such an error is caught, an error screen will be displayed with a button to let him logout and go to login page.', + }); export const isErrorApiData = z.discriminatedUnion("type", [ isErrorApiErrorData, diff --git a/messages/JsonMessages/MapDetailsData.ts b/messages/JsonMessages/MapDetailsData.ts index 79407fdf..2c8a119d 100644 --- a/messages/JsonMessages/MapDetailsData.ts +++ b/messages/JsonMessages/MapDetailsData.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import {extendApi} from "@anatine/zod-openapi"; /* * WARNING! The original file is in /messages/JsonMessages. @@ -6,20 +7,21 @@ import { z } from "zod"; */ export const isMapDetailsData = z.object({ - mapUrl: z.string(), - authenticationMandatory: z.optional(z.nullable(z.boolean())), - group: z.nullable(z.string()), + // @ts-ignore + mapUrl: extendApi(z.string(), {description: 'The full URL to the JSON map file', example: 'https://myuser.github.io/myrepo/map.json'}), + authenticationMandatory: extendApi(z.optional(z.nullable(z.boolean())), {description: 'Whether the authentication is mandatory or not for this map', example: true}), + group: extendApi(z.nullable(z.string()), {description: 'The group this room is part of (maps the notion of "world" in WorkAdventure SAAS)', example: 'myorg/myworld'}), - contactPage: z.optional(z.nullable(z.string())), - iframeAuthentication: z.optional(z.nullable(z.string())), + contactPage: extendApi(z.optional(z.nullable(z.string())), {description: 'The URL to the contact page', example: 'https://mycompany.com/contact-us'}), + iframeAuthentication: extendApi(z.optional(z.nullable(z.string())), {description: 'The URL of the authentication Iframe', example: 'https://mycompany.com/authc'}), // The date (in ISO 8601 format) at which the room will expire - expireOn: z.optional(z.string()), + expireOn: extendApi(z.optional(z.string()), {description: 'The date (in ISO 8601 format) at which the room will expire', example: '2022-11-05T08:15:30-05:00'}), // Whether the "report" feature is enabled or not on this room - canReport: z.optional(z.boolean()), + canReport: extendApi(z.optional(z.boolean()), {description: 'Whether the "report" feature is enabled or not on this room', example: true}), // The URL of the logo image on the loading screen - loadingLogo: z.optional(z.nullable(z.string())), + loadingLogo: extendApi(z.optional(z.nullable(z.string())), {description: 'The URL of the image to be used on the loading page', example: 'https://example.com/logo.png'}), // The URL of the logo image on "LoginScene" - loginSceneLogo: z.optional(z.nullable(z.string())), + loginSceneLogo: extendApi(z.optional(z.nullable(z.string())), {description: 'The URL of the image to be used on the LoginScene', example: 'https://example.com/logo_login.png'}), }); export type MapDetailsData = z.infer; diff --git a/messages/package.json b/messages/package.json index 1dba4f5a..78f96919 100644 --- a/messages/package.json +++ b/messages/package.json @@ -18,8 +18,10 @@ "pretty-check": "yarn prettier --check 'JsonMessages/**/*.ts'" }, "dependencies": { + "@anatine/zod-openapi": "^1.3.0", "google-protobuf": "^3.13.0", "grpc": "^1.24.4", + "openapi3-ts": "^2.0.2", "ts-proto": "^1.96.0", "zod": "^3.14.3" }, diff --git a/messages/yarn.lock b/messages/yarn.lock index 131c433b..c2b538d1 100644 --- a/messages/yarn.lock +++ b/messages/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@anatine/zod-openapi@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@anatine/zod-openapi/-/zod-openapi-1.3.0.tgz#b5b38c3d821b79674226aa7b327c88c371860d0d" + integrity sha512-l54DypUdDsIq1Uwjv4ib9IBkTXMKZQLUj7qvdFL51EExC5LdSSqOlTOyaVVZZGYgWPKM7ZjGklhdoknLz4EC+w== + dependencies: + ts-deepmerge "^1.1.0" + validator "^13.7.0" + "@babel/code-frame@^7.0.0": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" @@ -3241,6 +3249,13 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +openapi3-ts@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz#a200dd838bf24c9086c8eedcfeb380b7eb31e82a" + integrity sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw== + dependencies: + yaml "^1.10.2" + optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" @@ -4284,6 +4299,11 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" +ts-deepmerge@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ts-deepmerge/-/ts-deepmerge-1.1.0.tgz#4236ae102199affe2e77690dcf198a420160eef2" + integrity sha512-VvwaV/6RyYMwT9d8dClmfHIsG2PCdm6WY430QKOIbPRR50Y/1Q2ilp4i2XEZeHFcNqfaYnAQzpyUC6XA0AqqBg== + ts-poet@^4.5.0: version "4.6.1" resolved "https://registry.yarnpkg.com/ts-poet/-/ts-poet-4.6.1.tgz#015dc823d726655af9f095c900f84ed7c60e2dd3" @@ -4487,6 +4507,11 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" +validator@^13.7.0: + version "13.7.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" + integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw== + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" @@ -4593,7 +4618,7 @@ yallist@^3.0.0, yallist@^3.0.3: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yaml@^1.10.0: +yaml@^1.10.0, yaml@^1.10.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== diff --git a/pusher/src/Controller/SwaggerController.ts b/pusher/src/Controller/SwaggerController.ts index 618b5266..8a2b0f72 100644 --- a/pusher/src/Controller/SwaggerController.ts +++ b/pusher/src/Controller/SwaggerController.ts @@ -1,9 +1,12 @@ import { BaseHttpController } from "./BaseHttpController"; import * as fs from "fs"; +import { ADMIN_URL } from "../Enum/EnvironmentVariable"; +import SwaggerGenerator from "../Services/SwaggerGenerator"; +import swaggerJsdoc from "swagger-jsdoc"; export class SwaggerController extends BaseHttpController { routes() { - this.app.get("/openapi", (req, res) => { + this.app.get("/openapi/pusher", (req, res) => { // Let's load the module dynamically (it may not exist in prod because part of the -dev packages) const swaggerJsdoc = require("swagger-jsdoc"); const options = { @@ -20,6 +23,43 @@ export class SwaggerController extends BaseHttpController { res.json(swaggerJsdoc(options)); }); + this.app.get("/openapi/admin", (req, res) => { + // Let's load the module dynamically (it may not exist in prod because part of the -dev packages) + const swaggerJsdoc = require("swagger-jsdoc"); + const options = { + swaggerDefinition: { + swagger: "2.0", + //openapi: "3.0.0", + info: { + title: "WorkAdventure Pusher", + version: "1.0.0", + description: "This is a documentation about the endpoints called by the pusher. \n You can find out more about WorkAdventure on [github](https://github.com/thecodingmachine/workadventure).", + contact: + { + email: "hello@workadventu.re" + } + }, + "host": "pusher." + ADMIN_URL.replace('//',''), + "tags": [ + { + "name": "AdminAPI", + "description": "Access to end points of the admin from the pusher" + }, + ], + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + }, + ...SwaggerGenerator.definitions() + }, + apis: ["./src/Services/*.ts"], + }; + res.json(swaggerJsdoc(options)); + }); + // Create a LiveDirectory instance to virtualize directory with our assets // @ts-ignore const LiveDirectory = require("live-directory"); @@ -39,8 +79,13 @@ export class SwaggerController extends BaseHttpController { if (err) { return response.status(500).send(err.message); } - const result = data.replace(/https:\/\/petstore\.swagger\.io\/v2\/swagger.json/g, "/openapi"); + const urls = [ + {url: "/openapi/pusher", name: "Front -> Pusher"}, + {url: "/openapi/admin", name: "Pusher <- Admin"}, + ]; + + const result = data.replace(/url: "https:\/\/petstore\.swagger\.io\/v2\/swagger.json"/g, `urls: ${JSON.stringify(urls)}, "urls.primaryName": "Pusher <- Admin"`); response.send(result); return; diff --git a/pusher/src/Model/Websocket/Admin/AdminMessages.ts b/pusher/src/Model/Websocket/Admin/AdminMessages.ts index 1dc18ae5..15b5e58e 100644 --- a/pusher/src/Model/Websocket/Admin/AdminMessages.ts +++ b/pusher/src/Model/Websocket/Admin/AdminMessages.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import {extendApi} from "@anatine/zod-openapi"; export const isBanBannedAdminMessageInterface = z.object({ type: z.enum(["ban", "banned"]), @@ -8,7 +9,7 @@ export const isBanBannedAdminMessageInterface = z.object({ export const isUserMessageAdminMessageInterface = z.object({ event: z.enum(["user-message"]), - message: isBanBannedAdminMessageInterface, + message: extendApi(isBanBannedAdminMessageInterface, {$ref: "#/definitions/BanBannedAdminMessageInterface"}), world: z.string(), jwt: z.string(), }); diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index b8f84141..eddd3832 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -9,6 +9,7 @@ import qs from "qs"; import { AdminInterface } from "./AdminInterface"; import { AuthTokenData, jwtTokenManager } from "./JWTTokenManager"; import { InvalidTokenError } from "../Controller/InvalidTokenError"; +import {extendApi} from "@anatine/zod-openapi"; export interface AdminBannedData { is_banned: boolean; @@ -16,15 +17,16 @@ export interface AdminBannedData { } export const isFetchMemberDataByUuidResponse = z.object({ - email: z.string(), - userUuid: z.string(), - tags: z.array(z.string()), - visitCardUrl: z.nullable(z.string()), - textures: z.array(isWokaDetail), - messages: z.array(z.unknown()), + // @ts-ignore + email: extendApi(z.string(), {description: 'The email of the fetched user, it can be an email, an uuid or undefined.', example: "example@workadventu.re"}), + userUuid: extendApi(z.string(), {description: 'The uuid of the fetched user, it can be an email, an uuid or undefined.', example: "998ce839-3dea-4698-8b41-ebbdf7688ad9"}), + tags: extendApi(z.array(z.string()), {description: 'List of tags related to the user fetched.', example: ['editor']}), + visitCardUrl: extendApi(z.nullable(z.string()), {description: 'URL of the visitCard of the user fetched.', example: 'https://mycompany.com/contact/me'}), + textures: extendApi(z.array(isWokaDetail), {$ref: "#/definitions/WokaDetail"}), + messages: extendApi(z.array(z.unknown()), {description: 'List of user\'s messages.'}), - anonymous: z.optional(z.boolean()), - userRoomToken: z.optional(z.string()), + anonymous: extendApi(z.optional(z.boolean()), {description: 'Whether the user if logged as anonymous or not', example: false}), + userRoomToken: extendApi(z.optional(z.string()), {description: '', example: ''}), }); export type FetchMemberDataByUuidResponse = z.infer; @@ -69,6 +71,47 @@ class AdminApi implements AdminInterface { userId, }; + /** + * @openapi + * /api/map: + * get: + * tags: ["AdminAPI"] + * description: Returns a map mapping map name to file name of the map + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "playUri" + * in: "query" + * description: "The full URL of WorkAdventure" + * required: true + * type: "string" + * example: "http://play.workadventure.localhost/@/teamSlug/worldSLug/roomSlug" + * - name: "userId" + * in: "query" + * description: "The identifier of the current user \n It can be undefined or an uuid or an email" + * type: "string" + * example: "998ce839-3dea-4698-8b41-ebbdf7688ad9" + * responses: + * 200: + * description: The details of the member + * schema: + * $ref: "#/definitions/MapDetailsData" + * 401: + * description: Error while retrieving the data because you are not authorized + * schema: + * $ref: '#/definitions/ErrorApiRedirectData' + * 403: + * description: Error while retrieving the data because you are not authorized + * schema: + * $ref: '#/definitions/ErrorApiUnauthorizedData' + * 404: + * description: Error while retrieving the data + * schema: + * $ref: '#/definitions/ErrorApiErrorData' + * + */ const res = await Axios.get>(ADMIN_API_URL + "/api/map", { headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, params, @@ -99,6 +142,58 @@ class AdminApi implements AdminInterface { characterLayers: string[], locale?: string ): Promise { + /** + * @openapi + * /api/room/access: + * get: + * tags: ["AdminAPI"] + * description: Returns member's informations if he can access this room + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "userIdentifier" + * in: "query" + * description: "The identifier of the current user \n It can be undefined or an uuid or an email" + * type: "string" + * example: "998ce839-3dea-4698-8b41-ebbdf7688ad9" + * - name: "playUri" + * in: "query" + * description: "The full URL of WorkAdventure" + * required: true + * type: "string" + * example: "http://play.workadventure.localhost/@/teamSlug/worldSLug/roomSlug" + * - name: "ipAddress" + * in: "query" + * required: true + * type: "string" + * example: "127.0.0.1" + * - name: "characterLayers" + * in: "query" + * type: "array" + * items: + * type: string + * example: ["male1"] + * responses: + * 200: + * description: The details of the member + * schema: + * $ref: "#/definitions/FetchMemberDataByUuidResponse" + * 401: + * description: Error while retrieving the data because you are not authorized + * schema: + * $ref: '#/definitions/ErrorApiRedirectData' + * 403: + * description: Error while retrieving the data because you are not authorized + * schema: + * $ref: '#/definitions/ErrorApiUnauthorizedData' + * 404: + * description: Error while retrieving the data + * schema: + * $ref: '#/definitions/ErrorApiErrorData' + * + */ const res = await Axios.get>(ADMIN_API_URL + "/api/room/access", { params: { userIdentifier, @@ -130,6 +225,42 @@ class AdminApi implements AdminInterface { playUri: string | null, locale?: string ): Promise { + /** + * @openapi + * /api/login-url/{organizationMemberToken}: + * get: + * tags: ["AdminAPI"] + * description: Returns a member from the token + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "organizationMemberToken" + * in: "path" + * description: "The token of member in the organization" + * type: "string" + * - name: "playUri" + * in: "query" + * description: "The full URL of WorkAdventure" + * required: true + * type: "string" + * example: "http://play.workadventure.localhost/@/teamSlug/worldSLug/roomSlug" + * responses: + * 200: + * description: The details of the member + * schema: + * $ref: "#/definitions/AdminApiData" + * 401: + * description: Error while retrieving the data because you are not authorized + * schema: + * $ref: '#/definitions/ErrorApiRedirectData' + * 404: + * description: Error while retrieving the data + * schema: + * $ref: '#/definitions/ErrorApiErrorData' + * + */ //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. const res = await Axios.get(ADMIN_API_URL + "/api/login-url/" + organizationMemberToken, { params: { playUri }, @@ -154,6 +285,41 @@ class AdminApi implements AdminInterface { reportWorldSlug: string, locale?: string ) { + /** + * @openapi + * /api/report: + * post: + * tags: ["AdminAPI"] + * description: Report one user with a comment + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "reportedUserUuid" + * in: "query" + * description: "The identifier of the reported user \n It can be an uuid or an email" + * type: "string" + * example: "998ce839-3dea-4698-8b41-ebbdf7688ad9" + * - name: "reportedUserComment" + * in: "query" + * description: "The comment of the report" + * required: true + * type: "string" + * - name: "reporterUserUuid" + * in: "query" + * description: "The identifier of the reporter user \n It can be an uuid or an email" + * type: "string" + * example: "998ce839-3dea-4698-8b41-ebbdf7688ad8" + * - name: "reportWorldSlug" + * in: "query" + * description: "The slug of the world where the report is made" + * type: "string" + * example: "/@/teamSlug/worldSlug/roomSlug" + * responses: + * 200: + * description: The report has been successfully saved + */ return Axios.post( `${ADMIN_API_URL}/api/report`, { @@ -174,6 +340,53 @@ class AdminApi implements AdminInterface { roomUrl: string, locale?: string ): Promise { + /** + * @openapi + * /api/ban: + * get: + * tags: ["AdminAPI"] + * description: Check if user is banned or not + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "ipAddress" + * in: "query" + * type: "string" + * required: true + * example: "127.0.0.1" + * - name: "token" + * in: "query" + * description: "The uuid of the user \n It can be an uuid or an email" + * type: "string" + * required: true + * example: "998ce839-3dea-4698-8b41-ebbdf7688ad8" + * - name: "roomUrl" + * in: "query" + * description: "The slug of the world where to check if the user is banned" + * type: "string" + * required: true + * example: "/@/teamSlug/worldSlug/roomSlug" + * responses: + * 200: + * description: The user is banned or not + * content: + * application/json: + * schema: + * type: array + * required: + * - is_banned + * properties: + * is_banned: + * type: boolean + * description: Whether the user is banned or not + * example: true + * 404: + * description: Error while retrieving the data + * schema: + * $ref: '#/definitions/ErrorApiErrorData' + */ //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. return Axios.get( ADMIN_API_URL + @@ -191,6 +404,37 @@ class AdminApi implements AdminInterface { } async getUrlRoomsFromSameWorld(roomUrl: string, locale?: string): Promise { + /** + * @openapi + * /api/room/sameWorld: + * get: + * tags: ["AdminAPI"] + * description: Get all URLs of the rooms from the world specified + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "roomUrl" + * in: "query" + * description: "The slug of the room" + * type: "string" + * required: true + * example: "/@/teamSlug/worldSlug/roomSlug" + * responses: + * 200: + * description: The list of URL of the rooms from the same world + * schema: + * type: array + * items: + * type: string + * description: URL of a room + * example: "http://example.com/@/teamSlug/worldSlug/room2Slug" + * 404: + * description: Error while retrieving the data + * schema: + * $ref: '#/definitions/ErrorApiErrorData' + */ return Axios.get(ADMIN_API_URL + "/api/room/sameWorld" + "?roomUrl=" + encodeURIComponent(roomUrl), { headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, }).then((data) => { @@ -204,10 +448,6 @@ class AdminApi implements AdminInterface { } return `${OPID_PROFILE_SCREEN_PROVIDER}?accessToken=${accessToken}`; } - - async logoutOauth(token: string): Promise { - await Axios.get(ADMIN_API_URL + `/oauth/logout?token=${token}`); - } } export const adminApi = new AdminApi(); diff --git a/pusher/src/Services/AdminInterface.ts b/pusher/src/Services/AdminInterface.ts index e70a653a..afdf4a9e 100644 --- a/pusher/src/Services/AdminInterface.ts +++ b/pusher/src/Services/AdminInterface.ts @@ -74,9 +74,4 @@ export interface AdminInterface { * @return string */ getProfileUrl(accessToken: string): string; - - /** - * @param token - */ - logoutOauth(token: string): Promise; } diff --git a/pusher/src/Services/LocalAdmin.ts b/pusher/src/Services/LocalAdmin.ts index 8470efb9..469966b3 100644 --- a/pusher/src/Services/LocalAdmin.ts +++ b/pusher/src/Services/LocalAdmin.ts @@ -87,10 +87,6 @@ class LocalAdmin implements AdminInterface { new Error("No admin backoffice set!"); return ""; } - - async logoutOauth(token: string): Promise { - return Promise.reject(new Error("No admin backoffice set!")); - } } export const localAdmin = new LocalAdmin(); diff --git a/pusher/src/Services/SwaggerGenerator.ts b/pusher/src/Services/SwaggerGenerator.ts new file mode 100644 index 00000000..15fe9d4a --- /dev/null +++ b/pusher/src/Services/SwaggerGenerator.ts @@ -0,0 +1,42 @@ +import {generateSchema} from "@anatine/zod-openapi"; +import {isAdminApiData} from "../Messages/JsonMessages/AdminApiData"; +import { + isErrorApiErrorData, + isErrorApiRedirectData, + isErrorApiRetryData, + isErrorApiUnauthorizedData +} from "../Messages/JsonMessages/ErrorApiData"; +import {isMapDetailsData} from "../Messages/JsonMessages/MapDetailsData"; +import {isRegisterData} from "../Messages/JsonMessages/RegisterData"; +import {isRoomRedirect} from "../Messages/JsonMessages/RoomRedirect"; +import {isFetchMemberDataByUuidResponse} from "./AdminApi"; +import { + isBanBannedAdminMessageInterface, isListenRoomsMessageInterface, + isUserMessageAdminMessageInterface +} from "../Model/Websocket/Admin/AdminMessages"; +import {isWokaDetail} from "../Messages/JsonMessages/PlayerTextures"; + +class SwaggerGenerator { + definitions() { + return { + definitions: + { + AdminApiData: generateSchema(isAdminApiData), + //BanBannedAdminMessageInterface: generateSchema(isBanBannedAdminMessageInterface), + ErrorApiErrorData: generateSchema(isErrorApiErrorData), + ErrorApiRedirectData: generateSchema(isErrorApiRedirectData), + ErrorApiRetryData: generateSchema(isErrorApiRetryData), + ErrorApiUnauthorizedData: generateSchema(isErrorApiUnauthorizedData), + FetchMemberDataByUuidResponse: generateSchema(isFetchMemberDataByUuidResponse), + //ListenRoomsMessageInterface: generateSchema(isListenRoomsMessageInterface), + MapDetailsData: generateSchema(isMapDetailsData), + //RegisterData: generateSchema(isRegisterData), + //RoomRedirect: generateSchema(isRoomRedirect), + //UserMessageAdminMessageInterface: generateSchema(isUserMessageAdminMessageInterface), + WokaDetail: generateSchema(isWokaDetail), + } + }; + } +} + +export default new SwaggerGenerator();