initial ai
This commit is contained in:
+573
-15
@@ -1,14 +1,122 @@
|
||||
import express from "express";
|
||||
import crypto from "node:crypto";
|
||||
import express, { Request, Response } from "express";
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
type GoogleRequestBody = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
baseUrl?: string;
|
||||
path?: string;
|
||||
query?: Record<string, string | number | boolean | null | undefined>;
|
||||
headers?: Record<string, string>;
|
||||
body?: JsonValue;
|
||||
accessToken?: string;
|
||||
accessTokenEnv?: string;
|
||||
apiKey?: string;
|
||||
apiKeyEnv?: string;
|
||||
};
|
||||
|
||||
type TokenExchangeBody = {
|
||||
grantType?: string;
|
||||
code?: string;
|
||||
refreshToken?: string;
|
||||
redirectUri?: string;
|
||||
scope?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
};
|
||||
|
||||
type ServiceAccountTokenBody = {
|
||||
scopes?: string[];
|
||||
scope?: string;
|
||||
subject?: string;
|
||||
serviceAccountEmail?: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const rootPath = process.env.ROOT_PATH || "";
|
||||
const rootPath = normalizeRootPath(process.env.ROOT_PATH || "");
|
||||
|
||||
const googleHosts = new Set([
|
||||
"accounts.google.com",
|
||||
"androidpublisher.googleapis.com",
|
||||
"analyticsadmin.googleapis.com",
|
||||
"analyticsdata.googleapis.com",
|
||||
"bigquery.googleapis.com",
|
||||
"blogger.googleapis.com",
|
||||
"books.googleapis.com",
|
||||
"calendar-json.googleapis.com",
|
||||
"calendar.googleapis.com",
|
||||
"chat.googleapis.com",
|
||||
"classroom.googleapis.com",
|
||||
"cloudbilling.googleapis.com",
|
||||
"cloudidentity.googleapis.com",
|
||||
"cloudresourcemanager.googleapis.com",
|
||||
"compute.googleapis.com",
|
||||
"contacts.googleapis.com",
|
||||
"content.googleapis.com",
|
||||
"customsearch.googleapis.com",
|
||||
"datastore.googleapis.com",
|
||||
"dialogflow.googleapis.com",
|
||||
"discovery.googleapis.com",
|
||||
"displayvideo.googleapis.com",
|
||||
"dns.googleapis.com",
|
||||
"docs.googleapis.com",
|
||||
"drive.googleapis.com",
|
||||
"firebase.googleapis.com",
|
||||
"firebaseappdistribution.googleapis.com",
|
||||
"firebasehosting.googleapis.com",
|
||||
"firestore.googleapis.com",
|
||||
"forms.googleapis.com",
|
||||
"gmail.googleapis.com",
|
||||
"googleads.googleapis.com",
|
||||
"groupssettings.googleapis.com",
|
||||
"iam.googleapis.com",
|
||||
"iamcredentials.googleapis.com",
|
||||
"indexing.googleapis.com",
|
||||
"kgsearch.googleapis.com",
|
||||
"language.googleapis.com",
|
||||
"licensing.googleapis.com",
|
||||
"logging.googleapis.com",
|
||||
"monitoring.googleapis.com",
|
||||
"mybusinessaccountmanagement.googleapis.com",
|
||||
"mybusinessbusinessinformation.googleapis.com",
|
||||
"mybusinessnotifications.googleapis.com",
|
||||
"oauth2.googleapis.com",
|
||||
"people.googleapis.com",
|
||||
"photoslibrary.googleapis.com",
|
||||
"playdeveloperreporting.googleapis.com",
|
||||
"pubsub.googleapis.com",
|
||||
"run.googleapis.com",
|
||||
"sheets.googleapis.com",
|
||||
"slides.googleapis.com",
|
||||
"sqladmin.googleapis.com",
|
||||
"storage.googleapis.com",
|
||||
"sts.googleapis.com",
|
||||
"tagmanager.googleapis.com",
|
||||
"tasks.googleapis.com",
|
||||
"translate.googleapis.com",
|
||||
"vision.googleapis.com",
|
||||
"walletobjects.googleapis.com",
|
||||
"www.googleapis.com",
|
||||
"youtube.googleapis.com",
|
||||
"youtubeanalytics.googleapis.com",
|
||||
"youtubereporting.googleapis.com"
|
||||
]);
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", true);
|
||||
app.use(express.json({ limit: "20mb" }));
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.json({
|
||||
name: "google-service",
|
||||
service: "google-service",
|
||||
status: "ok"
|
||||
status: "ok",
|
||||
docs: withRootPath("/docs"),
|
||||
openapi: withRootPath("/openapi.json")
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,20 +124,470 @@ app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
if (rootPath) {
|
||||
app.get(rootPath, (_req, res) => {
|
||||
res.json({
|
||||
name: "google-service",
|
||||
service: "google-service",
|
||||
status: "ok"
|
||||
});
|
||||
});
|
||||
app.get("/docs", (_req, res) => {
|
||||
res.type("html").send(renderDocsHtml());
|
||||
});
|
||||
|
||||
app.get(rootPath + "/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
}
|
||||
app.get("/openapi.json", (_req, res) => {
|
||||
res.json(buildOpenApiDocument());
|
||||
});
|
||||
|
||||
app.get("/google/discovery/apis", async (_req, res) => {
|
||||
try {
|
||||
await pipeGoogleJson(res, "https://www.googleapis.com/discovery/v1/apis");
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/google/discovery/apis/:api/:version/rest", async (req, res) => {
|
||||
try {
|
||||
const api = encodeURIComponent(req.params.api);
|
||||
const version = encodeURIComponent(req.params.version);
|
||||
await pipeGoogleJson(res, `https://www.googleapis.com/discovery/v1/apis/${api}/${version}/rest`);
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/google/oauth/token", async (req, res) => {
|
||||
try {
|
||||
const body = req.body as TokenExchangeBody;
|
||||
const clientId = body.clientId || process.env.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = body.clientSecret || process.env.GOOGLE_CLIENT_SECRET;
|
||||
const grantType = body.grantType || (body.refreshToken ? "refresh_token" : "authorization_code");
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return badRequest(res, "Missing GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or clientId/clientSecret.");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: grantType
|
||||
});
|
||||
|
||||
if (grantType === "authorization_code") {
|
||||
if (!body.code || !body.redirectUri) {
|
||||
return badRequest(res, "Authorization code exchange requires code and redirectUri.");
|
||||
}
|
||||
params.set("code", body.code);
|
||||
params.set("redirect_uri", body.redirectUri);
|
||||
} else if (grantType === "refresh_token") {
|
||||
if (!body.refreshToken) {
|
||||
return badRequest(res, "Refresh token exchange requires refreshToken.");
|
||||
}
|
||||
params.set("refresh_token", body.refreshToken);
|
||||
} else {
|
||||
return badRequest(res, "Supported grantType values are authorization_code and refresh_token.");
|
||||
}
|
||||
|
||||
if (body.scope) {
|
||||
params.set("scope", body.scope);
|
||||
}
|
||||
|
||||
await pipeGoogleForm(res, "https://oauth2.googleapis.com/token", params);
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/google/oauth/service-account-token", async (req, res) => {
|
||||
try {
|
||||
const body = req.body as ServiceAccountTokenBody;
|
||||
const email = body.serviceAccountEmail || process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
||||
const rawPrivateKey = body.privateKey || process.env.GOOGLE_PRIVATE_KEY;
|
||||
const privateKey = rawPrivateKey?.replace(/\\n/g, "\n");
|
||||
const scope = body.scope || body.scopes?.join(" ") || process.env.GOOGLE_SCOPES;
|
||||
|
||||
if (!email || !privateKey || !scope) {
|
||||
return badRequest(
|
||||
res,
|
||||
"Service account flow requires GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY and GOOGLE_SCOPES or matching request fields."
|
||||
);
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claimSet: Record<string, string | number> = {
|
||||
iss: email,
|
||||
scope,
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: now,
|
||||
exp: now + 3600
|
||||
};
|
||||
|
||||
if (body.subject) {
|
||||
claimSet.sub = body.subject;
|
||||
}
|
||||
|
||||
const assertion = signJwt({ alg: "RS256", typ: "JWT" }, claimSet, privateKey);
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion
|
||||
});
|
||||
|
||||
await pipeGoogleForm(res, "https://oauth2.googleapis.com/token", params);
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/google/oauth/revoke", async (req, res) => {
|
||||
try {
|
||||
const token = readRequiredString(req.body, "token");
|
||||
await pipeGoogleForm(res, "https://oauth2.googleapis.com/revoke", new URLSearchParams({ token }));
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/google/oauth/tokeninfo", async (req, res) => {
|
||||
try {
|
||||
const token = String(req.query.access_token || req.query.id_token || "");
|
||||
if (!token) {
|
||||
return badRequest(res, "Query must include access_token or id_token.");
|
||||
}
|
||||
|
||||
const url = new URL("https://oauth2.googleapis.com/tokeninfo");
|
||||
if (req.query.id_token) {
|
||||
url.searchParams.set("id_token", token);
|
||||
} else {
|
||||
url.searchParams.set("access_token", token);
|
||||
}
|
||||
|
||||
await pipeGoogleJson(res, url.toString());
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/google/request", async (req, res) => {
|
||||
try {
|
||||
const requestBody = req.body as GoogleRequestBody;
|
||||
const targetUrl = buildGoogleUrl(requestBody);
|
||||
const headers = buildGoogleHeaders(req, requestBody);
|
||||
const method = (requestBody.method || "GET").toUpperCase();
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
if (!["GET", "HEAD"].includes(method) && requestBody.body !== undefined) {
|
||||
init.body = JSON.stringify(requestBody.body);
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, init);
|
||||
await relayResponse(res, response);
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
});
|
||||
|
||||
app.use((error: unknown, _req: Request, res: Response, _next: unknown) => {
|
||||
sendError(res, error);
|
||||
});
|
||||
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
console.log("google-service listening on port " + port);
|
||||
});
|
||||
|
||||
function normalizeRootPath(value: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withLeadingSlash = value.startsWith("/") ? value : "/" + value;
|
||||
return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
|
||||
}
|
||||
|
||||
function withRootPath(path: string): string {
|
||||
return rootPath + path;
|
||||
}
|
||||
|
||||
function buildGoogleUrl(body: GoogleRequestBody): string {
|
||||
const rawUrl = body.url || joinBaseAndPath(body.baseUrl || "https://www.googleapis.com", body.path || "");
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("Only https Google API URLs are allowed.");
|
||||
}
|
||||
|
||||
if (!isAllowedGoogleHost(url.hostname)) {
|
||||
throw new Error("Only Google API hosts are allowed.");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(body.query || {})) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = body.apiKey || readEnvValue(body.apiKeyEnv) || process.env.GOOGLE_API_KEY;
|
||||
if (apiKey && !url.searchParams.has("key")) {
|
||||
url.searchParams.set("key", apiKey);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function joinBaseAndPath(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const normalizedPath = path.startsWith("/") ? path : "/" + path;
|
||||
return normalizedBase + normalizedPath;
|
||||
}
|
||||
|
||||
function isAllowedGoogleHost(hostname: string): boolean {
|
||||
return googleHosts.has(hostname) || hostname.endsWith(".googleapis.com") || hostname.endsWith(".google.com");
|
||||
}
|
||||
|
||||
function buildGoogleHeaders(req: Request, body: GoogleRequestBody): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set("accept", "application/json");
|
||||
|
||||
for (const [key, value] of Object.entries(body.headers || {})) {
|
||||
if (!["host", "connection", "content-length"].includes(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const bearer = body.accessToken || readEnvValue(body.accessTokenEnv) || readBearerToken(req);
|
||||
if (bearer) {
|
||||
headers.set("authorization", bearer.toLowerCase().startsWith("bearer ") ? bearer : `Bearer ${bearer}`);
|
||||
}
|
||||
|
||||
if (body.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function readBearerToken(req: Request): string | undefined {
|
||||
const authorization = req.header("authorization");
|
||||
if (!authorization?.toLowerCase().startsWith("bearer ")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return authorization.slice("bearer ".length);
|
||||
}
|
||||
|
||||
function readEnvValue(name: string | undefined): string | undefined {
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return process.env[name];
|
||||
}
|
||||
|
||||
async function pipeGoogleJson(res: Response, url: string): Promise<void> {
|
||||
const response = await fetch(url, { headers: { accept: "application/json" } });
|
||||
await relayResponse(res, response);
|
||||
}
|
||||
|
||||
async function pipeGoogleForm(res: Response, url: string, params: URLSearchParams): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
await relayResponse(res, response);
|
||||
}
|
||||
|
||||
async function relayResponse(res: Response, response: globalThis.Response): Promise<void> {
|
||||
const contentType = response.headers.get("content-type") || "application/json";
|
||||
const text = await response.text();
|
||||
res.status(response.status).type(contentType);
|
||||
|
||||
if (!text) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
function signJwt(header: Record<string, string>, payload: Record<string, string | number>, privateKey: string): string {
|
||||
const encodedHeader = base64Url(JSON.stringify(header));
|
||||
const encodedPayload = base64Url(JSON.stringify(payload));
|
||||
const input = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = crypto.createSign("RSA-SHA256").update(input).sign(privateKey);
|
||||
return `${input}.${base64Url(signature)}`;
|
||||
}
|
||||
|
||||
function base64Url(input: string | Buffer): string {
|
||||
return Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown, key: string): string {
|
||||
if (!value || typeof value !== "object" || typeof (value as Record<string, unknown>)[key] !== "string") {
|
||||
throw new Error(`Missing required string field: ${key}`);
|
||||
}
|
||||
|
||||
return (value as Record<string, string>)[key];
|
||||
}
|
||||
|
||||
function badRequest(res: Response, message: string): Response {
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
function sendError(res: Response, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
function renderDocsHtml(): string {
|
||||
const specUrl = withRootPath("/openapi.json");
|
||||
return `<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>google-service API</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; background: #f7f7f7; }
|
||||
.topbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: ${JSON.stringify(specUrl)},
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildOpenApiDocument(): Record<string, unknown> {
|
||||
return {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "google-service API",
|
||||
version: "1.0.0",
|
||||
description: "Obecna proxy a OAuth vrstva pro komunikaci s Google API."
|
||||
},
|
||||
servers: [{ url: rootPath || "/" }],
|
||||
tags: [
|
||||
{ name: "System" },
|
||||
{ name: "Google Discovery" },
|
||||
{ name: "Google OAuth" },
|
||||
{ name: "Google API" }
|
||||
],
|
||||
paths: {
|
||||
"/health": {
|
||||
get: {
|
||||
tags: ["System"],
|
||||
summary: "Health check",
|
||||
responses: { "200": { description: "Service is ready" } }
|
||||
}
|
||||
},
|
||||
"/google/discovery/apis": {
|
||||
get: {
|
||||
tags: ["Google Discovery"],
|
||||
summary: "Seznam verejnych Google API z Discovery service",
|
||||
responses: { "200": { description: "Google Discovery API list" } }
|
||||
}
|
||||
},
|
||||
"/google/discovery/apis/{api}/{version}/rest": {
|
||||
get: {
|
||||
tags: ["Google Discovery"],
|
||||
summary: "Discovery dokument konkretniho Google API",
|
||||
parameters: [
|
||||
{ name: "api", in: "path", required: true, schema: { type: "string" }, example: "drive" },
|
||||
{ name: "version", in: "path", required: true, schema: { type: "string" }, example: "v3" }
|
||||
],
|
||||
responses: { "200": { description: "Google Discovery REST document" } }
|
||||
}
|
||||
},
|
||||
"/google/oauth/token": {
|
||||
post: {
|
||||
tags: ["Google OAuth"],
|
||||
summary: "Vymena authorization code nebo refresh tokenu za access token",
|
||||
requestBody: jsonRequestBody({
|
||||
grantType: "authorization_code",
|
||||
code: "authorization-code",
|
||||
redirectUri: "https://example.test/oauth/callback"
|
||||
}),
|
||||
responses: { "200": { description: "OAuth token response" }, "400": { description: "Invalid request" } }
|
||||
}
|
||||
},
|
||||
"/google/oauth/service-account-token": {
|
||||
post: {
|
||||
tags: ["Google OAuth"],
|
||||
summary: "Vystaveni access tokenu pro service account JWT flow",
|
||||
requestBody: jsonRequestBody({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }),
|
||||
responses: { "200": { description: "OAuth token response" }, "400": { description: "Invalid request" } }
|
||||
}
|
||||
},
|
||||
"/google/oauth/revoke": {
|
||||
post: {
|
||||
tags: ["Google OAuth"],
|
||||
summary: "Revokace Google OAuth tokenu",
|
||||
requestBody: jsonRequestBody({ token: "token-to-revoke" }),
|
||||
responses: { "200": { description: "Token revoked" }, "400": { description: "Invalid request" } }
|
||||
}
|
||||
},
|
||||
"/google/oauth/tokeninfo": {
|
||||
get: {
|
||||
tags: ["Google OAuth"],
|
||||
summary: "Informace o access tokenu nebo ID tokenu",
|
||||
parameters: [
|
||||
{ name: "access_token", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "id_token", in: "query", required: false, schema: { type: "string" } }
|
||||
],
|
||||
responses: { "200": { description: "Token info" }, "400": { description: "Invalid request" } }
|
||||
}
|
||||
},
|
||||
"/google/request": {
|
||||
post: {
|
||||
tags: ["Google API"],
|
||||
summary: "Obecne volani Google REST API",
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: jsonRequestBody({
|
||||
method: "GET",
|
||||
baseUrl: "https://www.googleapis.com",
|
||||
path: "/drive/v3/files",
|
||||
query: { pageSize: 10 },
|
||||
accessTokenEnv: "GOOGLE_ACCESS_TOKEN"
|
||||
}),
|
||||
responses: { "200": { description: "Google API response" }, "400": { description: "Invalid request" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRequestBody(example: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user