diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 56d906739..58cfa0258 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -5884,6 +5884,353 @@ ] } }, + "/dl/desktop/{channel}/{plat}/{arch}/latest": { + "get": { + "operationId": "get_latest_desktop_version", + "summary": "Get latest desktop version", + "tags": ["Downloads"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VersionInfoResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get latest desktop version"}}, + "description": "Returns metadata for the latest desktop version including download URLs and SHA-256 checksums for all available formats. Pass ?test=1 to resolve against unreleased test builds.", + "parameters": [ + { + "name": "channel", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The channel" + }, + {"name": "plat", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The plat"}, + {"name": "arch", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The arch"}, + { + "name": "test", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/." + } + } + ] + } + }, + "/dl/desktop/{channel}/{plat}/{arch}/latest/{format}": { + "get": { + "operationId": "download_latest_desktop_version", + "summary": "Download latest desktop version", + "tags": ["Downloads"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Download latest desktop version"}}, + "description": "Streams the latest available desktop application version for the specified platform and architecture. Pass ?test=1 to download an unreleased test build.", + "parameters": [ + { + "name": "channel", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The channel" + }, + {"name": "plat", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The plat"}, + {"name": "arch", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The arch"}, + {"name": "format", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The format"}, + { + "name": "test", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/." + } + } + ] + } + }, + "/dl/desktop/{channel}/{plat}/{arch}/versions": { + "get": { + "operationId": "list_desktop_versions", + "summary": "List desktop versions", + "tags": ["Downloads"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DesktopVersionsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "List desktop versions"}}, + "description": "Lists available desktop versions with pagination for the specified platform and architecture.", + "parameters": [ + { + "name": "channel", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The channel" + }, + {"name": "plat", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The plat"}, + {"name": "arch", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The arch"}, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "format": "int32", + "description": "Maximum number of versions to return" + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Return versions before this version" + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Return versions after this version" + } + }, + { + "name": "test", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/." + } + } + ] + } + }, + "/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}": { + "get": { + "operationId": "download_desktop_version", + "summary": "Download desktop version", + "tags": ["Downloads"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Download desktop version"}}, + "description": "Streams a specific desktop application version for the given platform and architecture. Pass ?test=1 to download an unreleased test build.", + "parameters": [ + { + "name": "channel", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The channel" + }, + {"name": "plat", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The plat"}, + {"name": "arch", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The arch"}, + { + "name": "version", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The version" + }, + {"name": "format", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The format"}, + { + "name": "test", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/." + } + } + ] + } + }, "/donations/checkout": { "post": { "operationId": "create_donation_checkout", @@ -6207,6 +6554,370 @@ "description": "Retrieves gateway connection information and recommended shard count for establishing WebSocket connections." } }, + "/gifs/featured": { + "get": { + "operationId": "get_featured_gifs", + "summary": "Get featured GIFs", + "tags": ["GIFs"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GifFeaturedResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get featured GIFs"}}, + "description": "Retrieves currently featured GIFs and category tags from the active provider.", + "security": [{"sessionToken": []}], + "parameters": [ + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/gifs/register-share": { + "post": { + "operationId": "register_gifs_share", + "summary": "Register a GIF share", + "tags": ["GIFs"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Register a GIF share"}}, + "description": "Notifies the active GIF provider that the caller is sharing one of its GIFs.", + "security": [{"sessionToken": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GifRegisterShareRequest"}}} + } + } + }, + "/gifs/search": { + "get": { + "operationId": "search_gifs", + "summary": "Search GIFs", + "tags": ["GIFs"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/GifResponse"}}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Search GIFs"}}, + "description": "Searches the active GIF provider for GIFs matching the given query. The provider name is returned in the X-Fluxer-GIF-Provider response header so clients can adapt without refetching .well-known.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": {"type": "string", "description": "The search query"} + }, + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/gifs/suggest": { + "get": { + "operationId": "get_gifs_search_suggestions", + "summary": "Get GIF search suggestions", + "tags": ["GIFs"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get GIF search suggestions"}}, + "description": "Returns search-term suggestions from the active GIF provider for the given partial query.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": {"type": "string", "description": "The search query"} + }, + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/gifs/trending": { + "get": { + "operationId": "get_trending_gifs", + "summary": "Get trending GIFs", + "tags": ["GIFs"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/GifResponse"}}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get trending GIFs"}}, + "description": "Retrieves trending GIFs from the active provider.", + "security": [{"sessionToken": []}], + "parameters": [ + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, "/gifts/{code}": { "get": { "operationId": "get_gift_code", @@ -7131,6 +7842,160 @@ } } }, + "/guilds/{guild_id}/clone-emoji-disabled": { + "patch": { + "operationId": "toggle_clone_emoji_disabled", + "summary": "Toggle emoji cloning disabled", + "tags": ["Guilds"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Toggle emoji cloning disabled"}}, + "description": "Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's emojis. Note that this does not prevent users from saving and re-uploading the image manually.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}} + } + }, + "/guilds/{guild_id}/clone-sticker-disabled": { + "patch": { + "operationId": "toggle_clone_sticker_disabled", + "summary": "Toggle sticker cloning disabled", + "tags": ["Guilds"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Toggle sticker cloning disabled"}}, + "description": "Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's stickers. Note that this does not prevent users from saving and re-uploading the image manually.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}} + } + }, "/guilds/{guild_id}/delete": { "post": { "operationId": "delete_guild", @@ -7208,6 +8073,83 @@ } } }, + "/guilds/{guild_id}/detached-banner": { + "patch": { + "operationId": "toggle_detached_banner", + "summary": "Toggle detached banner", + "tags": ["Guilds"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Toggle detached banner"}}, + "description": "Requires manage_guild permission. Enables or disables independent banner display configuration.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}} + } + }, "/guilds/{guild_id}/discovery": { "post": { "operationId": "apply_for_discovery", @@ -7996,6 +8938,83 @@ ] } }, + "/guilds/{guild_id}/hide-owner-crown": { + "patch": { + "operationId": "toggle_hide_owner_crown", + "summary": "Toggle hide community owner crown", + "tags": ["Guilds"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Toggle hide community owner crown"}}, + "description": "Requires manage_guild permission. When enabled, the community owner crown icon is hidden across the UI for this guild.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}} + } + }, "/guilds/{guild_id}/invites": { "get": { "operationId": "list_guild_invites", @@ -8076,6 +9095,83 @@ ] } }, + "/guilds/{guild_id}/invites-disabled": { + "patch": { + "operationId": "toggle_invites_disabled", + "summary": "Toggle invites disabled", + "tags": ["Guilds"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Toggle invites disabled"}}, + "description": "Requires manage_guild permission. Pauses or resumes invite-link joins for this guild.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}} + } + }, "/guilds/{guild_id}/members": { "get": { "operationId": "list_guild_members", @@ -9854,6 +10950,83 @@ ] } }, + "/guilds/{guild_id}/text-channel-flexible-names": { + "patch": { + "operationId": "toggle_text_channel_flexible_names", + "summary": "Toggle text channel flexible names", + "tags": ["Guilds"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Toggle text channel flexible names"}}, + "description": "Requires manage_guild permission. Allows or disables flexible naming for text channels.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}} + } + }, "/guilds/{guild_id}/transfer-ownership": { "post": { "operationId": "transfer_guild_ownership", @@ -10502,6 +11675,370 @@ "description": "Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable." } }, + "/klipy/featured": { + "get": { + "operationId": "get_featured_klipy", + "summary": "Get featured GIFs (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GifFeaturedResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get featured GIFs (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/featured.", + "security": [{"sessionToken": []}], + "parameters": [ + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/klipy/register-share": { + "post": { + "operationId": "register_klipy_share", + "summary": "Register a GIF share (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Register a GIF share (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/register-share.", + "security": [{"sessionToken": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GifRegisterShareRequest"}}} + } + } + }, + "/klipy/search": { + "get": { + "operationId": "search_klipy", + "summary": "Search GIFs (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/GifResponse"}}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Search GIFs (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/search.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": {"type": "string", "description": "The search query"} + }, + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/klipy/suggest": { + "get": { + "operationId": "get_klipy_search_suggestions", + "summary": "Get GIF search suggestions (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get GIF search suggestions (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/suggest.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": {"type": "string", "description": "The search query"} + }, + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/klipy/trending-gifs": { + "get": { + "operationId": "get_trending_klipy", + "summary": "Get trending GIFs (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/GifResponse"}}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get trending GIFs (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/trending.", + "security": [{"sessionToken": []}], + "parameters": [ + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, "/oauth2/@me": { "get": { "operationId": "get_current_user_oauth2", @@ -14009,6 +15546,370 @@ "description": "Handles incoming Stripe webhook events for payment processing and subscription management." } }, + "/tenor/featured": { + "get": { + "operationId": "get_featured_tenor", + "summary": "Get featured GIFs (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GifFeaturedResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get featured GIFs (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/featured.", + "security": [{"sessionToken": []}], + "parameters": [ + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/tenor/register-share": { + "post": { + "operationId": "register_tenor_share", + "summary": "Register a GIF share (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Register a GIF share (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/register-share.", + "security": [{"sessionToken": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GifRegisterShareRequest"}}} + } + } + }, + "/tenor/search": { + "get": { + "operationId": "search_tenor", + "summary": "Search GIFs (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/GifResponse"}}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Search GIFs (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/search.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": {"type": "string", "description": "The search query"} + }, + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/tenor/suggest": { + "get": { + "operationId": "get_tenor_search_suggestions", + "summary": "Get GIF search suggestions (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get GIF search suggestions (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/suggest.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": {"type": "string", "description": "The search query"} + }, + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, + "/tenor/trending-gifs": { + "get": { + "operationId": "get_trending_tenor", + "summary": "Get trending GIFs (deprecated alias)", + "tags": ["GIFs (Deprecated)"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/GifResponse"}}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get trending GIFs (deprecated alias)"}}, + "description": "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed. Routes to the active provider; identical behaviour to /gifs/trending.", + "security": [{"sessionToken": []}], + "parameters": [ + {"name": "locale", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Locale"}} + ] + } + }, "/unfurl": { "post": { "operationId": "debug_unfurl", @@ -27658,6 +29559,49 @@ }, "required": ["guilds", "total", "category_counts"] }, + "VersionInfoResponse": { + "type": "object", + "properties": { + "version": {"type": "string", "description": "Semantic version string (e.g., 1.0.0)"}, + "pub_date": {"type": "string", "description": "ISO 8601 date when this version was published"}, + "minimum_system_version": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Minimum operating system version required by this release, when applicable" + }, + "files": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "Download URL for this file"}, + "sha256": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "SHA-256 hash of the file for verification" + }, + "checksum_url": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Plain text .sha256 checksum file URL for this file" + } + }, + "required": ["url", "sha256", "checksum_url"] + }, + "description": "Map of package format to download files" + } + }, + "required": ["version", "pub_date", "files"] + }, + "DesktopVersionsResponse": { + "type": "object", + "properties": { + "versions": { + "type": "array", + "items": {"$ref": "#/components/schemas/VersionInfoResponse"}, + "description": "Array of available versions" + }, + "has_more": {"type": "boolean", "description": "Whether more versions are available to fetch"} + }, + "required": ["versions", "has_more"] + }, "DonationCheckoutResponse": { "type": "object", "properties": { @@ -27759,6 +29703,213 @@ }, "required": ["url", "shards", "session_start_limit"] }, + "GifFeaturedResponse": { + "type": "object", + "properties": { + "gifs": { + "type": "array", + "items": {"$ref": "#/components/schemas/GifResponse"}, + "description": "Array of featured GIFs." + }, + "categories": { + "type": "array", + "items": {"$ref": "#/components/schemas/GifCategoryTagResponse"}, + "description": "Array of GIF categories." + } + }, + "required": ["gifs", "categories"] + }, + "GifResponse": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Provider-stable identifier for this GIF."}, + "slug": { + "type": "string", + "description": "Canonical slug (or slug-id token) used to share / re-resolve this GIF." + }, + "provider": {"type": "string", "description": "Name of the provider that produced this GIF."}, + "title": {"type": "string", "description": "Title or description of the GIF."}, + "url": {"type": "string", "description": "Provider page URL for the GIF."}, + "src": { + "type": "string", + "description": "Direct URL to the GIF media file (best format chosen by the server)." + }, + "proxy_src": { + "type": "string", + "description": "Proxied URL to the GIF media file (best format chosen by the server)." + }, + "width": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "description": "Width of the GIF in pixels (best format)." + }, + "height": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "description": "Height of the GIF in pixels (best format)." + }, + "media": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/GifMediaFormat"}, + "description": "Map of format-name → media descriptor. Keys are a size prefix (none for full size, \"medium\", \"tiny\", \"nano\") joined to a codec name (\"webm\", \"mp4\", \"webp\", \"gif\"), plus \"loopedmp4\". Video keys are \"webm\" / \"mp4\" / \"loopedmp4\" / \"mediumwebm\" / \"mediummp4\" / \"tinywebm\" / \"tinymp4\" / \"nanowebm\" / \"nanomp4\"; image keys are \"webp\" / \"gif\" / \"mediumwebp\" / \"mediumgif\" / \"tinywebp\" / \"tinygif\" / \"nanowebp\" / \"nanogif\". Every key is optional, so clients must walk a priority list rather than index a single key. Clients that cannot decode the video keys should prefer \"tinywebp\" / \"tinygif\" / \"mediumwebp\" / \"mediumgif\" / \"webp\" / \"gif\" / \"nanowebp\" / \"nanogif\" in that order." + }, + "placeholder": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Compact thumbhash placeholder produced by the media proxy. Clients render it as a low-res preview while the GIF loads, and persist it on favourites so the picker has a fallback if the source URL later disappears." + } + }, + "required": ["id", "slug", "provider", "title", "url", "src", "proxy_src", "width", "height", "media"] + }, + "GifCategoryTagResponse": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Category search term (locale-translated label suitable for display)." + }, + "src": { + "type": "string", + "description": "Category preview image URL from the top GIF for this category search term." + }, + "proxy_src": { + "type": "string", + "description": "Proxied category preview image URL from the top GIF for this category search term." + }, + "gif": { + "anyOf": [{"$ref": "#/components/schemas/GifResponse"}, {"type": "null"}], + "description": "Enriched category preview GIF from the top search result for this category. Null only when no preview GIF was available." + } + }, + "required": ["name", "src", "proxy_src", "gif"] + }, + "Locale": { + "type": "string", + "enum": [ + "ar", + "bg", + "cs", + "da", + "de", + "el", + "en-GB", + "en-US", + "es-ES", + "es-419", + "fi", + "fr", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "lt", + "nl", + "no", + "pl", + "pt-BR", + "ro", + "ru", + "sv-SE", + "th", + "tr", + "uk", + "vi", + "zh-CN", + "zh-TW" + ], + "x-enumNames": [ + "AR", + "BG", + "CS", + "DA", + "DE", + "EL", + "EN_GB", + "EN_US", + "ES_ES", + "ES_419", + "FI", + "FR", + "HE", + "HI", + "HR", + "HU", + "ID", + "IT", + "JA", + "KO", + "LT", + "NL", + "NO", + "PL", + "PT_BR", + "RO", + "RU", + "SV_SE", + "TH", + "TR", + "UK", + "VI", + "ZH_CN", + "ZH_TW" + ], + "x-enumDescriptions": [ + "Arabic", + "Bulgarian", + "Czech", + "Danish", + "German", + "Greek", + "English (United Kingdom)", + "English (United States)", + "Spanish (Spain)", + "Spanish (Latin America)", + "Finnish", + "French", + "Hebrew", + "Hindi", + "Croatian", + "Hungarian", + "Indonesian", + "Italian", + "Japanese", + "Korean", + "Lithuanian", + "Dutch", + "Norwegian", + "Polish", + "Portuguese (Brazil)", + "Romanian", + "Russian", + "Swedish", + "Thai", + "Turkish", + "Ukrainian", + "Vietnamese", + "Chinese (Simplified)", + "Chinese (Traditional)" + ], + "description": "The locale code for the user interface language" + }, + "GifRegisterShareRequest": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Provider-issued share identifier (slug or slug-id token)."}, + "q": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Optional search query that produced the GIF." + }, + "locale": {"$ref": "#/components/schemas/Locale"} + }, + "required": ["id"] + }, "GiftCodeResponse": { "type": "object", "properties": { @@ -34591,118 +36742,6 @@ "default_share_voice_activity" ] }, - "Locale": { - "type": "string", - "enum": [ - "ar", - "bg", - "cs", - "da", - "de", - "el", - "en-GB", - "en-US", - "es-ES", - "es-419", - "fi", - "fr", - "he", - "hi", - "hr", - "hu", - "id", - "it", - "ja", - "ko", - "lt", - "nl", - "no", - "pl", - "pt-BR", - "ro", - "ru", - "sv-SE", - "th", - "tr", - "uk", - "vi", - "zh-CN", - "zh-TW" - ], - "x-enumNames": [ - "AR", - "BG", - "CS", - "DA", - "DE", - "EL", - "EN_GB", - "EN_US", - "ES_ES", - "ES_419", - "FI", - "FR", - "HE", - "HI", - "HR", - "HU", - "ID", - "IT", - "JA", - "KO", - "LT", - "NL", - "NO", - "PL", - "PT_BR", - "RO", - "RU", - "SV_SE", - "TH", - "TR", - "UK", - "VI", - "ZH_CN", - "ZH_TW" - ], - "x-enumDescriptions": [ - "Arabic", - "Bulgarian", - "Czech", - "Danish", - "German", - "Greek", - "English (United Kingdom)", - "English (United States)", - "Spanish (Spain)", - "Spanish (Latin America)", - "Finnish", - "French", - "Hebrew", - "Hindi", - "Croatian", - "Hungarian", - "Indonesian", - "Italian", - "Japanese", - "Korean", - "Lithuanian", - "Dutch", - "Norwegian", - "Polish", - "Portuguese (Brazil)", - "Romanian", - "Russian", - "Swedish", - "Thai", - "Turkish", - "Ukrainian", - "Vietnamese", - "Chinese (Simplified)", - "Chinese (Traditional)" - ], - "description": "The locale code for the user interface language" - }, "StickerAnimationOptions": { "type": "integer", "format": "int32", @@ -36088,6 +38127,7 @@ {"name": "Read States", "description": "Message read state tracking"}, {"name": "Saved Media", "description": "User saved media management"}, {"name": "Themes", "description": "User interface themes"}, + {"name": "Downloads", "description": "App downloads"}, {"name": "Reports", "description": "Content reporting"}, {"name": "Instance", "description": "Instance configuration and info"}, {"name": "Billing", "description": "Subscription and payment management via Stripe"}, @@ -36097,6 +38137,8 @@ {"name": "Messages"}, {"name": "Donations"}, {"name": "Geolocation"}, + {"name": "GIFs"}, + {"name": "GIFs (Deprecated)"}, {"name": "Discovery"}, {"name": "Emojis"}, {"name": "Stickers"}, diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 49d4a4c9b..87aa6ee20 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -8,6 +8,7 @@ }, "scripts": { "generate": "tsx src/scripts/GenerateSpec.ts", + "test": "vitest run", "typecheck": "tsgo --noEmit", "validate": "tsx src/scripts/GenerateSpec.ts --validate-only" }, @@ -21,6 +22,8 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "prettier": "^3.8.3", - "tsx": "catalog:" + "tsx": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/openapi/src/OpenAPIGenerationTypes.ts b/packages/openapi/src/OpenAPIGenerationTypes.ts index 2f4af12f5..d4022ebd8 100644 --- a/packages/openapi/src/OpenAPIGenerationTypes.ts +++ b/packages/openapi/src/OpenAPIGenerationTypes.ts @@ -9,11 +9,19 @@ export interface OpenAPIGeneratorOptions { readonly serverUrl?: string; readonly routeScope?: OpenAPIRouteScope; } +export interface SkippedRoute { + readonly method: string; + readonly path: string; + readonly source: string; + readonly reason: string; +} export interface OpenAPIGenerationStats { readonly controllerCount: number; readonly routeCount: number; readonly operationCount: number; readonly skippedRouteCount: number; + readonly skippedRoutes: ReadonlyArray; + readonly untemplatableRoutes: ReadonlyArray; readonly registeredSchemaCount: number; readonly publishedSchemaCount: number; readonly tagCount: number; diff --git a/packages/openapi/src/OpenAPIGenerator.ts b/packages/openapi/src/OpenAPIGenerator.ts index e48414c04..71c5fd319 100644 --- a/packages/openapi/src/OpenAPIGenerator.ts +++ b/packages/openapi/src/OpenAPIGenerator.ts @@ -8,6 +8,7 @@ import type { OpenAPIGenerationResult, OpenAPIGeneratorOptions, OpenAPIRouteScope, + SkippedRoute, } from '@fluxer/openapi/src/OpenAPIGenerationTypes'; import type {ExtractedRoute, OpenAPIDocument, OpenAPIPathItem, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes'; import {convertPathToOpenAPI} from '@fluxer/openapi/src/registry/ParameterRegistry'; @@ -16,7 +17,11 @@ import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry'; interface PathBuildResult { readonly paths: Record; readonly operationCount: number; - readonly skippedRouteCount: number; + readonly skippedRoutes: ReadonlyArray; + readonly untemplatableRoutes: ReadonlyArray; +} +function hasOpenAPIPathTemplate(routePath: string): boolean { + return !routePath.includes('*') && !routePath.includes('{'); } interface GeneratorSettings { readonly basePath: string; @@ -36,6 +41,14 @@ function createGeneratorSettings(options: OpenAPIGeneratorOptions): GeneratorSet routeScope: options.routeScope ?? 'public', }; } +function describeRoute(route: ExtractedRoute, reason: string): SkippedRoute { + return { + method: route.method.toUpperCase(), + path: route.path, + source: `${route.controllerFile}:${route.lineNumber.toString()}`, + reason, + }; +} function isAdminRoute(route: ExtractedRoute): boolean { return route.path === '/admin' || route.path.startsWith('/admin/'); } @@ -102,7 +115,9 @@ export class OpenAPIGenerator { controllerCount: controllerFiles.length, routeCount: routes.length, operationCount: pathBuildResult.operationCount, - skippedRouteCount: pathBuildResult.skippedRouteCount, + skippedRouteCount: pathBuildResult.skippedRoutes.length, + skippedRoutes: pathBuildResult.skippedRoutes, + untemplatableRoutes: pathBuildResult.untemplatableRoutes, registeredSchemaCount, publishedSchemaCount: Object.keys(publishedSchemas).length, tagCount: tags.length, @@ -122,13 +137,18 @@ export class OpenAPIGenerator { private buildPaths(routes: Array, operationBuilder: OpenAPIOperationBuilder): PathBuildResult { const paths: Record = {}; let operationCount = 0; - let skippedRouteCount = 0; + const skippedRoutes: Array = []; + const untemplatableRoutes: Array = []; for (const route of routes) { if (isExcludedRoutePath(route.path)) { continue; } if (!route.responseSchemaName && !route.hasNoContent) { - skippedRouteCount++; + skippedRoutes.push(describeRoute(route, 'no responseSchema and no NoContent()')); + continue; + } + if (!hasOpenAPIPathTemplate(route.path)) { + untemplatableRoutes.push(describeRoute(route, 'the Hono path has no OpenAPI path template')); continue; } const openApiPath = convertPathToOpenAPI(route.path); @@ -143,7 +163,8 @@ export class OpenAPIGenerator { return { paths: sortedPaths, operationCount, - skippedRouteCount, + skippedRoutes, + untemplatableRoutes, }; } private filterPublishedSchemas( diff --git a/packages/openapi/src/extractors/RouteExtractor.ts b/packages/openapi/src/extractors/RouteExtractor.ts index 7b925a98e..8800be9b6 100644 --- a/packages/openapi/src/extractors/RouteExtractor.ts +++ b/packages/openapi/src/extractors/RouteExtractor.ts @@ -1,6 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import { + EMPTY_SCOPE, + type Scope, + StaticPathResolver, + type StaticValue, + UNRESOLVED, +} from '@fluxer/openapi/src/extractors/StaticPathResolver'; import type {ExtractedRoute, ExtractedValidator, HttpMethod, ValidatorTarget} from '@fluxer/openapi/src/Types'; -import {type CallExpression, Node, Project, type SourceFile} from 'ts-morph'; +import {type CallExpression, type FunctionDeclaration, Node, Project, type SourceFile} from 'ts-morph'; const HTTP_METHODS: ReadonlySet = new Set(['get', 'post', 'put', 'patch', 'delete']); function isHttpMethod(method: string): method is HttpMethod { @@ -45,10 +52,24 @@ function extractOAuth2ScopeArgs(args: ReadonlyArray): Array | null } return scopes.length > 0 ? scopes : null; } -function extractObjectLiteralValue(node: Node): unknown { +interface MetadataContext { + readonly resolver: StaticPathResolver; + readonly scope: Scope; +} +function resolveMetadataText(node: Node, context: MetadataContext | null): string | null { + if (context == null) { + return null; + } + const value = context.resolver.resolve(node, context.scope); + return typeof value === 'string' ? value : null; +} +function extractObjectLiteralValue(node: Node, context: MetadataContext | null): unknown { if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { return node.getLiteralValue(); } + if (Node.isTemplateExpression(node) || Node.isConditionalExpression(node)) { + return resolveMetadataText(node, context); + } if (Node.isNumericLiteral(node)) { return Number.parseFloat(node.getText()); } @@ -65,13 +86,26 @@ function extractObjectLiteralValue(node: Node): unknown { return node.getText(); } if (Node.isPropertyAccessExpression(node)) { - return node.getText(); + return resolveMetadataText(node, context) ?? node.getText(); } if (Node.isCallExpression(node)) { return node.getText(); } if (Node.isArrayLiteralExpression(node)) { - return node.getElements().map((el) => extractObjectLiteralValue(el)); + const values: Array = []; + for (const element of node.getElements()) { + if (Node.isSpreadElement(element)) { + const spread = context?.resolver.resolve(element.getExpression(), context.scope); + if (spread == null || spread === UNRESOLVED || !Array.isArray(spread)) { + values.push(null); + continue; + } + values.push(...spread); + continue; + } + values.push(extractObjectLiteralValue(element, context)); + } + return values; } if (Node.isObjectLiteralExpression(node)) { const result: Record = {}; @@ -80,7 +114,7 @@ function extractObjectLiteralValue(node: Node): unknown { const key = prop.getName(); const initializer = prop.getInitializer(); if (initializer) { - result[key] = extractObjectLiteralValue(initializer); + result[key] = extractObjectLiteralValue(initializer, context); } } } @@ -88,9 +122,9 @@ function extractObjectLiteralValue(node: Node): unknown { } return null; } -function parseObjectLiteralMetadata(objLiteral: Node): Record { +function parseObjectLiteralMetadata(objLiteral: Node, context: MetadataContext | null): Record { if (!Node.isObjectLiteralExpression(objLiteral)) return {}; - return extractObjectLiteralValue(objLiteral) as Record; + return extractObjectLiteralValue(objLiteral, context) as Record; } function extractValidatorInfo(callExpr: CallExpression): ExtractedValidator | null { const expression = callExpr.getExpression(); @@ -162,7 +196,7 @@ interface MiddlewareInfo { description?: string; } | null; } -function extractMiddlewareInfo(callExpr: CallExpression): MiddlewareInfo | null { +function extractMiddlewareInfo(callExpr: CallExpression, context: MetadataContext | null): MiddlewareInfo | null { const expression = callExpr.getExpression(); if (Node.isIdentifier(expression)) { const name = expression.getText(); @@ -245,7 +279,7 @@ function extractMiddlewareInfo(callExpr: CallExpression): MiddlewareInfo | null if (args.length === 0) return null; const firstArg = args[0]; if (Node.isObjectLiteralExpression(firstArg)) { - const metadata = parseObjectLiteralMetadata(firstArg); + const metadata = parseObjectLiteralMetadata(firstArg, context); const operationId = typeof metadata.operationId === 'string' ? metadata.operationId : null; const summary = typeof metadata.summary === 'string' ? metadata.summary : null; const description = typeof metadata.description === 'string' ? metadata.description : null; @@ -479,24 +513,142 @@ function extractSuccessStatusCodes(handler: Node): Array { }); return Array.from(codes).sort((a, b) => a - b); } -function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): ExtractedRoute | null { +interface RegistrationCall { + readonly call: CallExpression; + readonly methods: ReadonlyArray; + readonly pathArgument: Node; + readonly middlewareArguments: ReadonlyArray; +} +interface UnresolvedRegistration { + readonly filePath: string; + readonly lineNumber: number; + readonly methods: string; + readonly expression: string; +} +function methodsFromOnArgument(node: Node, resolver: StaticPathResolver, scope: Scope): Array | null { + const value = resolver.resolve(node, scope); + if (value === UNRESOLVED) { + return null; + } + const entries: Array = Array.isArray(value) ? [...value] : [value]; + const methods: Array = []; + for (const entry of entries) { + if (typeof entry !== 'string') { + return null; + } + const lowered = entry.toLowerCase(); + if (lowered === 'head') { + continue; + } + if (!isHttpMethod(lowered)) { + return null; + } + methods.push(lowered); + } + return methods.length > 0 ? methods : null; +} +const HONO_TYPE_PATTERN = /\bHono(App|Env)?\b/u; +function isHonoReceiver(receiver: Node): boolean { + if (!Node.isIdentifier(receiver)) { + return false; + } + const name = receiver.getText(); + for (const ancestor of receiver.getAncestors()) { + if ( + Node.isFunctionDeclaration(ancestor) || + Node.isArrowFunction(ancestor) || + Node.isFunctionExpression(ancestor) || + Node.isMethodDeclaration(ancestor) + ) { + for (const parameter of ancestor.getParameters()) { + const nameNode = parameter.getNameNode(); + if (Node.isIdentifier(nameNode) && nameNode.getText() === name) { + return HONO_TYPE_PATTERN.test(parameter.getTypeNode()?.getText() ?? ''); + } + } + } + if (Node.isBlock(ancestor) || Node.isSourceFile(ancestor)) { + for (const statement of ancestor.getStatements()) { + if (!Node.isVariableStatement(statement)) { + continue; + } + for (const declaration of statement.getDeclarations()) { + const nameNode = declaration.getNameNode(); + if (Node.isIdentifier(nameNode) && nameNode.getText() === name) { + const annotation = declaration.getTypeNode()?.getText() ?? ''; + const initializer = declaration.getInitializer()?.getText() ?? ''; + return HONO_TYPE_PATTERN.test(`${annotation} ${initializer}`); + } + } + } + } + } + return false; +} +function isRegistrationCall(callExpr: CallExpression): boolean { + const expression = callExpr.getExpression(); + if (!Node.isPropertyAccessExpression(expression)) { + return false; + } + if (!isHonoReceiver(expression.getExpression())) { + return false; + } + const name = expression.getName().toLowerCase(); + const args = callExpr.getArguments(); + if (isHttpMethod(name)) { + return args.length >= 2; + } + return name === 'on' && args.length >= 3; +} +function pathArgumentOf(callExpr: CallExpression): Node | null { const expression = callExpr.getExpression(); if (!Node.isPropertyAccessExpression(expression)) { return null; } - const method = expression.getName().toLowerCase(); - if (!isHttpMethod(method)) { - return null; - } const args = callExpr.getArguments(); - if (args.length < 2) { + return expression.getName().toLowerCase() === 'on' ? (args[1] ?? null) : (args[0] ?? null); +} +function readRegistrationCall( + callExpr: CallExpression, + resolver: StaticPathResolver, + scope: Scope, +): RegistrationCall | null { + if (!isRegistrationCall(callExpr)) { return null; } - const pathArg = args[0]; - const path = extractStringLiteral(pathArg); - if (!path) { + const expression = callExpr.getExpression(); + if (!Node.isPropertyAccessExpression(expression)) { return null; } + const name = expression.getName().toLowerCase(); + const args = callExpr.getArguments(); + if (isHttpMethod(name)) { + return { + call: callExpr, + methods: [name], + pathArgument: args[0], + middlewareArguments: args.slice(1), + }; + } + const methods = methodsFromOnArgument(args[0], resolver, scope); + if (methods == null) { + return null; + } + return { + call: callExpr, + methods, + pathArgument: args[1], + middlewareArguments: args.slice(2), + }; +} +function buildRoute( + registration: RegistrationCall, + method: HttpMethod, + routePath: string, + sourceFile: SourceFile, + resolver: StaticPathResolver, + scope: Scope, +): ExtractedRoute { const validators: Array = []; const middlewares: Array = []; let hasLoginRequired = false; @@ -525,8 +677,7 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): url: string; description?: string; } | null = null; - for (let i = 1; i < args.length; i++) { - const arg = args[i]; + for (const arg of registration.middlewareArguments) { if (Node.isIdentifier(arg)) { const name = arg.getText(); middlewares.push(name); @@ -544,7 +695,7 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): if (validatorInfo) { validators.push(validatorInfo); } else { - const middlewareInfo = extractMiddlewareInfo(arg); + const middlewareInfo = extractMiddlewareInfo(arg, {resolver, scope}); if (middlewareInfo) { middlewares.push(middlewareInfo.middlewareName); if (middlewareInfo.rateLimitConfig) { @@ -580,7 +731,7 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): if (middlewareInfo.oauth2RequiredScopes && middlewareInfo.oauth2ScopeMode) { if (oauth2ScopeMode && oauth2ScopeMode !== middlewareInfo.oauth2ScopeMode) { throw new Error( - `Cannot combine OAuth2 scope middleware modes on ${method.toUpperCase()} ${path} in ${sourceFile.getFilePath()}:${callExpr.getStartLineNumber()}`, + `Cannot combine OAuth2 scope middleware modes on ${method.toUpperCase()} ${routePath} in ${sourceFile.getFilePath()}:${registration.call.getStartLineNumber()}`, ); } oauth2ScopeMode = middlewareInfo.oauth2ScopeMode; @@ -615,9 +766,9 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): } return { method, - path, + path: routePath, controllerFile: sourceFile.getFilePath(), - lineNumber: callExpr.getStartLineNumber(), + lineNumber: registration.call.getStartLineNumber(), validators, middlewares, hasLoginRequired, @@ -645,16 +796,194 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): explicitExternalDocs, }; } -function findRoutesInSourceFile(sourceFile: SourceFile): Array { - const routes: Array = []; - sourceFile.forEachDescendant((node) => { - if (Node.isCallExpression(node)) { - const route = extractRouteFromCall(node, sourceFile); - if (route) { - routes.push(route); +function owningFunction(node: Node): FunctionDeclaration | null { + for (const ancestor of node.getAncestors()) { + if (Node.isFunctionDeclaration(ancestor)) { + return ancestor; + } + } + return null; +} +function bindParameters( + fn: FunctionDeclaration, + args: ReadonlyArray, + callerScope: Scope, + resolver: StaticPathResolver, +): Scope { + const scope = new Map(); + fn.getParameters().forEach((parameter, index) => { + const arg = args[index]; + if (arg == null) { + return; + } + const value = resolver.resolve(arg, callerScope); + if (value === UNRESOLVED) { + return; + } + const nameNode = parameter.getNameNode(); + if (Node.isIdentifier(nameNode)) { + scope.set(nameNode.getText(), value); + return; + } + if (Node.isObjectBindingPattern(nameNode) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + const record = value as {readonly [key: string]: StaticValue}; + for (const element of nameNode.getElements()) { + const key = element.getPropertyNameNode()?.getText() ?? element.getName(); + if (key in record) { + scope.set(element.getName(), record[key]); + } } } }); + return scope; +} +function scopesForFunction( + fn: FunctionDeclaration, + sourceFile: SourceFile, + resolver: StaticPathResolver, + visiting: Set, +): Array { + if (visiting.has(fn)) { + return [EMPTY_SCOPE]; + } + const name = fn.getName(); + if (name == null) { + return [EMPTY_SCOPE]; + } + visiting.add(fn); + try { + const scopes: Array = []; + sourceFile.forEachDescendant((node) => { + if (!Node.isCallExpression(node)) { + return; + } + const callee = node.getExpression(); + if (!Node.isIdentifier(callee) || callee.getText() !== name) { + return; + } + const enclosing = owningFunction(node); + const outerScopes = + enclosing == null || enclosing === fn + ? [EMPTY_SCOPE] + : scopesForFunction(enclosing, sourceFile, resolver, visiting); + for (const outerScope of outerScopes) { + for (const loopScope of expandLoops(node, enclosing, outerScope, resolver)) { + scopes.push(bindParameters(fn, node.getArguments(), loopScope, resolver)); + } + } + }); + return scopes.length > 0 ? scopes : [EMPTY_SCOPE]; + } finally { + visiting.delete(fn); + } +} +function expandLoops( + node: Node, + stopAt: FunctionDeclaration | null, + baseScope: Scope, + resolver: StaticPathResolver, +): Array { + const loops: Array = []; + for (const ancestor of node.getAncestors()) { + if (ancestor === stopAt || Node.isSourceFile(ancestor)) { + break; + } + if (Node.isForOfStatement(ancestor)) { + loops.push(ancestor); + } + } + let scopes: Array = [baseScope]; + for (const loop of loops.reverse()) { + if (!Node.isForOfStatement(loop)) { + continue; + } + const initializer = loop.getInitializer(); + if (!Node.isVariableDeclarationList(initializer)) { + return scopes; + } + const declaration = initializer.getDeclarations()[0]; + const nameNode = declaration?.getNameNode(); + if (nameNode == null || !Node.isIdentifier(nameNode)) { + return scopes; + } + const expanded: Array = []; + for (const scope of scopes) { + const iterated = resolver.resolve(loop.getExpression(), scope); + if (!Array.isArray(iterated)) { + return scopes; + } + for (const element of iterated) { + const next = new Map(scope); + next.set(nameNode.getText(), element); + expanded.push(next); + } + } + scopes = expanded; + } + return scopes; +} +function findRoutesInSourceFile( + sourceFile: SourceFile, + resolver: StaticPathResolver, + unresolved: Array, +): Array { + const registrations: Array = []; + sourceFile.forEachDescendant((node) => { + if (Node.isCallExpression(node)) { + registrations.push(node); + } + }); + const byOwner = new Map>(); + for (const call of registrations) { + if (!isRegistrationCall(call)) { + continue; + } + const owner = owningFunction(call); + const bucket = byOwner.get(owner); + if (bucket == null) { + byOwner.set(owner, [call]); + } else { + bucket.push(call); + } + } + const routes: Array = []; + for (const [owner, calls] of byOwner) { + const scopes = owner == null ? [EMPTY_SCOPE] : scopesForFunction(owner, sourceFile, resolver, new Set()); + for (const call of calls) { + const seen = new Set(); + let resolvedAny = false; + for (const scope of scopes) { + const registration = readRegistrationCall(call, resolver, scope); + if (registration == null) { + continue; + } + const routePath = resolver.resolveString(registration.pathArgument, scope); + if (routePath == null) { + continue; + } + resolvedAny = true; + for (const method of registration.methods) { + const key = `${method} ${routePath}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + routes.push(buildRoute(registration, method, routePath, sourceFile, resolver, scope)); + } + } + if (!resolvedAny) { + const expression = call.getExpression(); + const methodName = Node.isPropertyAccessExpression(expression) ? expression.getName().toUpperCase() : '?'; + const pathArgument = pathArgumentOf(call); + unresolved.push({ + filePath: sourceFile.getFilePath(), + lineNumber: call.getStartLineNumber(), + methods: methodName === 'ON' ? `ON ${call.getArguments()[0].getText()}` : methodName, + expression: (pathArgument ?? call).getText().replace(/\s+/gu, ' '), + }); + } + } + } return routes; } export function extractRoutesFromControllers(controllerPaths: Array): Array { @@ -662,16 +991,32 @@ export function extractRoutesFromControllers(controllerPaths: Array): Ar skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true, }); + const resolver = new StaticPathResolver(project); const routes: Array = []; + const unresolved: Array = []; for (const controllerPath of controllerPaths) { try { const sourceFile = project.addSourceFileAtPath(controllerPath); - const fileRoutes = findRoutesInSourceFile(sourceFile); + const fileRoutes = findRoutesInSourceFile(sourceFile, resolver, unresolved); routes.push(...fileRoutes); } catch (error) { console.warn(`Warning: Could not parse ${controllerPath}:`, error); } } + if (unresolved.length > 0) { + const lines = unresolved.map( + (entry) => ` ${entry.filePath}:${entry.lineNumber.toString()} ${entry.methods} ${entry.expression}`, + ); + throw new Error( + [ + `The route extractor could not read ${unresolved.length.toString()} route path(s). A path it cannot read is a route`, + 'that would vanish from openapi.json and from the docs coverage gate without a trace, so extraction', + 'stops here instead. Give the path a literal, or a const the resolver can follow, or teach', + 'packages/openapi/src/extractors/StaticPathResolver.ts to read the expression.', + ...lines, + ].join('\n'), + ); + } return routes; } export function discoverControllerFiles(apiPackagePath: string): Array { @@ -679,6 +1024,10 @@ export function discoverControllerFiles(apiPackagePath: string): Array { tsConfigFilePath: `${apiPackagePath}/tsconfig.json`, skipAddingFilesFromTsConfig: true, }); - const sourceFiles = project.addSourceFilesAtPaths([`${apiPackagePath}/src/**/*Controller.ts`]); + const sourceFiles = project.addSourceFilesAtPaths([ + `${apiPackagePath}/src/**/*.ts`, + `!${apiPackagePath}/src/**/*.test.ts`, + `!${apiPackagePath}/src/**/tests/**`, + ]); return sourceFiles.map((sf) => sf.getFilePath()); } diff --git a/packages/openapi/src/extractors/StaticPathResolver.ts b/packages/openapi/src/extractors/StaticPathResolver.ts new file mode 100644 index 000000000..d89ddcaba --- /dev/null +++ b/packages/openapi/src/extractors/StaticPathResolver.ts @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import * as path from 'node:path'; +import {Node, type Project, type SourceFile, SyntaxKind} from 'ts-morph'; + +export const UNRESOLVED = Symbol('unresolved'); + +export type StaticValue = + | string + | number + | boolean + | null + | ReadonlyArray + | {readonly [key: string]: StaticValue}; + +type Resolved = StaticValue | typeof UNRESOLVED; + +export type Scope = ReadonlyMap; + +export const EMPTY_SCOPE: Scope = new Map(); + +const MAX_DEPTH = 48; + +function isPlainObject(value: Resolved): value is {readonly [key: string]: StaticValue} { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isTruthy(value: StaticValue): boolean { + if (Array.isArray(value)) return true; + if (isPlainObject(value)) return true; + return Boolean(value); +} + +function unwrap(node: Node): Node { + let current = node; + while ( + Node.isParenthesizedExpression(current) || + Node.isAsExpression(current) || + Node.isSatisfiesExpression(current) || + Node.isNonNullExpression(current) || + Node.isTypeAssertion(current) + ) { + current = current.getExpression(); + } + return current; +} + +export class StaticPathResolver { + private readonly moduleConstants = new Map>(); + private readonly inFlight = new Set(); + + constructor(private readonly project: Project) {} + + public resolve(node: Node, scope: Scope): Resolved { + return this.evaluate(node, scope, 0); + } + + public resolveString(node: Node, scope: Scope): string | null { + const value = this.evaluate(node, scope, 0); + return typeof value === 'string' ? value : null; + } + + private evaluate(rawNode: Node, scope: Scope, depth: number): Resolved { + if (depth > MAX_DEPTH) { + return UNRESOLVED; + } + const node = unwrap(rawNode); + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { + return node.getLiteralValue(); + } + if (Node.isNumericLiteral(node)) { + return node.getLiteralValue(); + } + if (Node.isTrueLiteral(node)) { + return true; + } + if (Node.isFalseLiteral(node)) { + return false; + } + if (Node.isNullLiteral(node)) { + return null; + } + if (Node.isTemplateExpression(node)) { + let text = node.getHead().getLiteralText(); + for (const span of node.getTemplateSpans()) { + const value = this.evaluate(span.getExpression(), scope, depth + 1); + if (value === UNRESOLVED || Array.isArray(value) || isPlainObject(value)) { + return UNRESOLVED; + } + text += String(value); + text += span.getLiteral().getLiteralText(); + } + return text; + } + if (Node.isIdentifier(node)) { + return this.evaluateIdentifier(node, scope, depth); + } + if (Node.isPropertyAccessExpression(node)) { + const target = this.evaluate(node.getExpression(), scope, depth + 1); + if (!isPlainObject(target)) { + return UNRESOLVED; + } + const name = node.getName(); + return name in target ? target[name] : UNRESOLVED; + } + if (Node.isElementAccessExpression(node)) { + const target = this.evaluate(node.getExpression(), scope, depth + 1); + const argument = node.getArgumentExpression(); + if (argument == null || target === UNRESOLVED) { + return UNRESOLVED; + } + const key = this.evaluate(argument, scope, depth + 1); + if (typeof key !== 'string' && typeof key !== 'number') { + return UNRESOLVED; + } + if (Array.isArray(target)) { + const index = typeof key === 'number' ? key : Number.parseInt(key, 10); + return Number.isInteger(index) && index >= 0 && index < target.length ? target[index] : UNRESOLVED; + } + if (isPlainObject(target)) { + const name = String(key); + return name in target ? target[name] : UNRESOLVED; + } + return UNRESOLVED; + } + if (Node.isArrayLiteralExpression(node)) { + const values: Array = []; + for (const element of node.getElements()) { + if (Node.isSpreadElement(element)) { + return UNRESOLVED; + } + const value = this.evaluate(element, scope, depth + 1); + if (value === UNRESOLVED) { + return UNRESOLVED; + } + values.push(value); + } + return values; + } + if (Node.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.getProperties()) { + if (Node.isPropertyAssignment(property)) { + const initializer = property.getInitializer(); + if (initializer == null) { + return UNRESOLVED; + } + const value = this.evaluate(initializer, scope, depth + 1); + if (value === UNRESOLVED) { + continue; + } + result[property.getName()] = value; + continue; + } + if (Node.isShorthandPropertyAssignment(property)) { + const value = this.evaluate(property.getNameNode(), scope, depth + 1); + if (value === UNRESOLVED) { + continue; + } + result[property.getName()] = value; + continue; + } + return UNRESOLVED; + } + return result; + } + if (Node.isConditionalExpression(node)) { + const condition = this.evaluate(node.getCondition(), scope, depth + 1); + if (condition === UNRESOLVED) { + return UNRESOLVED; + } + return this.evaluate(isTruthy(condition) ? node.getWhenTrue() : node.getWhenFalse(), scope, depth + 1); + } + if (Node.isPrefixUnaryExpression(node)) { + if (node.getOperatorToken() !== SyntaxKind.ExclamationToken) { + return UNRESOLVED; + } + const operand = this.evaluate(node.getOperand(), scope, depth + 1); + return operand === UNRESOLVED ? UNRESOLVED : !isTruthy(operand); + } + if (Node.isBinaryExpression(node)) { + return this.evaluateBinary(node, scope, depth); + } + return UNRESOLVED; + } + + private evaluateBinary(node: Node, scope: Scope, depth: number): Resolved { + if (!Node.isBinaryExpression(node)) { + return UNRESOLVED; + } + const operator = node.getOperatorToken().getText(); + const left = this.evaluate(node.getLeft(), scope, depth + 1); + if (left === UNRESOLVED) { + return UNRESOLVED; + } + if (operator === '&&') { + return isTruthy(left) ? this.evaluate(node.getRight(), scope, depth + 1) : left; + } + if (operator === '||') { + return isTruthy(left) ? left : this.evaluate(node.getRight(), scope, depth + 1); + } + if (operator === '??') { + return left === null ? this.evaluate(node.getRight(), scope, depth + 1) : left; + } + const right = this.evaluate(node.getRight(), scope, depth + 1); + if (right === UNRESOLVED) { + return UNRESOLVED; + } + if (operator === '+') { + if (typeof left === 'string' && (typeof right === 'string' || typeof right === 'number')) { + return left + String(right); + } + if (typeof left === 'number' && typeof right === 'number') { + return left + right; + } + return UNRESOLVED; + } + const comparable = + (typeof left === 'string' || typeof left === 'number' || typeof left === 'boolean' || left === null) && + (typeof right === 'string' || typeof right === 'number' || typeof right === 'boolean' || right === null); + if (!comparable) { + return UNRESOLVED; + } + if (operator === '===' || operator === '==') { + return left === right; + } + if (operator === '!==' || operator === '!=') { + return left !== right; + } + return UNRESOLVED; + } + + private evaluateIdentifier(node: Node, scope: Scope, depth: number): Resolved { + if (!Node.isIdentifier(node)) { + return UNRESOLVED; + } + const name = node.getText(); + if (name === 'undefined') { + return UNRESOLVED; + } + const bound = scope.get(name); + if (bound !== undefined) { + return bound; + } + const local = this.findBindingInScopeChain(node, name); + if (local != null) { + return this.evaluateBinding(local, scope, depth); + } + return this.evaluateImportedConstant(node.getSourceFile(), name, depth); + } + + private findBindingInScopeChain(from: Node, name: string): Node | null { + for (const ancestor of from.getAncestors()) { + if (!Node.isBlock(ancestor) && !Node.isSourceFile(ancestor) && !Node.isCaseClause(ancestor)) { + continue; + } + for (const statement of ancestor.getStatements()) { + if (!Node.isVariableStatement(statement)) { + continue; + } + if (statement.getDeclarationKind() !== 'const') { + continue; + } + for (const declaration of statement.getDeclarations()) { + const nameNode = declaration.getNameNode(); + if (Node.isIdentifier(nameNode)) { + if (nameNode.getText() === name) { + return declaration; + } + continue; + } + if (Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) { + for (const element of nameNode.getElements()) { + if (Node.isBindingElement(element) && element.getName() === name) { + return element; + } + } + } + } + } + } + return null; + } + + private evaluateBinding(binding: Node, scope: Scope, depth: number): Resolved { + if (this.inFlight.has(binding)) { + return UNRESOLVED; + } + this.inFlight.add(binding); + try { + if (Node.isVariableDeclaration(binding)) { + const initializer = binding.getInitializer(); + return initializer == null ? UNRESOLVED : this.evaluate(initializer, scope, depth + 1); + } + if (!Node.isBindingElement(binding)) { + return UNRESOLVED; + } + if (binding.getDotDotDotToken() != null) { + return UNRESOLVED; + } + const pattern = binding.getParent(); + const declaration = pattern.getParent(); + if (!Node.isVariableDeclaration(declaration)) { + return UNRESOLVED; + } + const initializer = declaration.getInitializer(); + if (initializer == null) { + return UNRESOLVED; + } + const source = this.evaluate(initializer, scope, depth + 1); + if (source === UNRESOLVED) { + return UNRESOLVED; + } + if (Node.isObjectBindingPattern(pattern)) { + if (!isPlainObject(source)) { + return UNRESOLVED; + } + const key = binding.getPropertyNameNode()?.getText() ?? binding.getName(); + return key in source ? source[key] : UNRESOLVED; + } + if (Node.isArrayBindingPattern(pattern)) { + if (!Array.isArray(source)) { + return UNRESOLVED; + } + const index = pattern.getElements().indexOf(binding); + return index >= 0 && index < source.length ? source[index] : UNRESOLVED; + } + return UNRESOLVED; + } finally { + this.inFlight.delete(binding); + } + } + + private evaluateImportedConstant(sourceFile: SourceFile, name: string, depth: number): Resolved { + const target = this.resolveImportTarget(sourceFile, name); + if (target == null) { + return UNRESOLVED; + } + const constants = this.constantsOf(target.sourceFile); + const initializer = constants.get(target.exportedName); + if (initializer == null) { + return UNRESOLVED; + } + return this.evaluate(initializer, EMPTY_SCOPE, depth + 1); + } + + private resolveImportTarget( + sourceFile: SourceFile, + name: string, + ): {sourceFile: SourceFile; exportedName: string} | null { + for (const declaration of sourceFile.getImportDeclarations()) { + for (const named of declaration.getNamedImports()) { + const localName = named.getAliasNode()?.getText() ?? named.getName(); + if (localName !== name) { + continue; + } + const resolved = this.resolveModule(sourceFile, declaration.getModuleSpecifierValue()); + if (resolved == null) { + return null; + } + return {sourceFile: resolved, exportedName: named.getName()}; + } + } + return null; + } + + private resolveModule(from: SourceFile, specifier: string): SourceFile | null { + if (!specifier.startsWith('.')) { + return null; + } + const base = path.resolve(path.dirname(from.getFilePath()), specifier); + for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, base]) { + const existing = this.project.getSourceFile(candidate); + if (existing != null) { + return existing; + } + const added = this.project.addSourceFileAtPathIfExists(candidate); + if (added != null) { + return added; + } + } + return null; + } + + private constantsOf(sourceFile: SourceFile): Map { + const cached = this.moduleConstants.get(sourceFile); + if (cached != null) { + return cached; + } + const constants = new Map(); + for (const statement of sourceFile.getVariableStatements()) { + if (statement.getDeclarationKind() !== 'const') { + continue; + } + for (const declaration of statement.getDeclarations()) { + const nameNode = declaration.getNameNode(); + const initializer = declaration.getInitializer(); + if (Node.isIdentifier(nameNode) && initializer != null) { + constants.set(nameNode.getText(), initializer); + } + } + } + this.moduleConstants.set(sourceFile, constants); + return constants; + } +} diff --git a/packages/openapi/src/extractors/__tests__/RouteDiscovery.test.ts b/packages/openapi/src/extractors/__tests__/RouteDiscovery.test.ts new file mode 100644 index 000000000..26c8df792 --- /dev/null +++ b/packages/openapi/src/extractors/__tests__/RouteDiscovery.test.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {discoverControllerFiles, extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor'; +import {beforeAll, describe, expect, it} from 'vitest'; + +const API_PACKAGE_PATH = path.join(fileURLToPath(new URL('../../../../../', import.meta.url)), 'fluxer_api'); + +describe('discoverControllerFiles', () => { + let files: Array; + let shapes: Set; + beforeAll(() => { + files = discoverControllerFiles(API_PACKAGE_PATH); + shapes = new Set(extractRoutesFromControllers(files).map((route) => `${route.method.toUpperCase()} ${route.path}`)); + }); + it('leaves test sources out of the discovered set', () => { + expect(files.filter((file) => file.endsWith('.test.ts'))).toEqual([]); + expect(files.filter((file) => file.includes('/tests/'))).toEqual([]); + }); + it('reads routes registered in a *Controller.ts file', () => { + expect(shapes).toContain('GET /gifs/search'); + expect(shapes).toContain('GET /gifs/featured'); + }); + it('reads routes registered outside a *Controller.ts file', () => { + expect(shapes).toContain('POST /webhooks/twilio/sms'); + expect(shapes).toContain('GET /_metrics'); + expect(shapes).toContain('GET /_health'); + }); +}); diff --git a/packages/openapi/src/scripts/GenerateSpec.ts b/packages/openapi/src/scripts/GenerateSpec.ts index 4316a8e3d..3fbeddb76 100644 --- a/packages/openapi/src/scripts/GenerateSpec.ts +++ b/packages/openapi/src/scripts/GenerateSpec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as fs from 'node:fs'; import * as path from 'node:path'; -import type {OpenAPIRouteScope} from '@fluxer/openapi/src/OpenAPIGenerationTypes'; +import type {OpenAPIGenerationStats, OpenAPIRouteScope, SkippedRoute} from '@fluxer/openapi/src/OpenAPIGenerationTypes'; import {OpenAPIGenerator} from '@fluxer/openapi/src/OpenAPIGenerator'; import {transformAdminOpenAPISpec} from '@fluxer/openapi/src/output/AdminSpecTransform'; import {printValidationResult, validateSpec} from '@fluxer/openapi/src/output/SpecValidator'; @@ -62,6 +62,21 @@ function getTargetOutputPath(basePath: string, target: GenerateTarget, customOut function getRouteScope(target: GenerateTarget): OpenAPIRouteScope { return target === 'admin' ? 'admin' : 'public'; } +function reportRoutesLeftOut(target: GenerateTarget, stats: OpenAPIGenerationStats): void { + const groups: Array<[string, ReadonlyArray]> = [ + ['registered but not written to the spec', stats.skippedRoutes], + ['registered but not expressible as an OpenAPI path', stats.untemplatableRoutes], + ]; + for (const [title, routes] of groups) { + if (routes.length === 0) { + continue; + } + console.log(`${target} routes ${title}: ${routes.length.toString()}`); + for (const route of [...routes].sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`))) { + console.log(` ${route.method} ${route.path} ${route.reason} (${route.source})`); + } + } +} async function buildTargetSpec(basePath: string, target: GenerateTarget): Promise { const generator = new OpenAPIGenerator({ basePath, @@ -71,11 +86,12 @@ async function buildTargetSpec(basePath: string, target: GenerateTarget): Promis serverUrl: 'https://api.fluxer.app/v1', routeScope: getRouteScope(target), }); - const spec = await generator.generate(); + const {document, stats} = await generator.generateWithStats(); + reportRoutesLeftOut(target, stats); if (target === 'admin') { - return transformAdminOpenAPISpec(spec); + return transformAdminOpenAPISpec(document); } - return spec; + return document; } function validateTargetSpec(target: GenerateTarget, spec: WritableOpenAPISpec): boolean { const validationResult = validateSpec(spec, { diff --git a/packages/openapi/vitest.config.ts b/packages/openapi/vitest.config.ts new file mode 100644 index 000000000..2505b9fe6 --- /dev/null +++ b/packages/openapi/vitest.config.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import {defineConfig} from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + root: path.resolve(__dirname, '../..'), + }), + ], + test: { + globals: true, + environment: 'node', + include: ['**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'dist'], + testTimeout: 60000, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b5976fa3..cbf86399c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2109,6 +2109,12 @@ importers: tsx: specifier: 'catalog:' version: 4.21.0 + vite-tsconfig-paths: + specifier: 'catalog:' + version: 6.1.1(typescript@5.9.3)(vite@7.3.6(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.12.0)(jsdom@28.1.0(@noble/hashes@2.4.0))(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) packages/schema: dependencies: @@ -10446,10 +10452,6 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -20076,11 +20078,6 @@ snapshots: tinyexec@1.0.2: {} - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -20376,7 +20373,7 @@ snapshots: std-env: 4.2.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 tinyrainbow: 3.1.1 vite: 7.3.6(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 @@ -20407,7 +20404,7 @@ snapshots: std-env: 4.2.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 tinyrainbow: 3.1.1 vite: 7.3.6(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0