rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
674 lines (661 loc) • 29 kB
JavaScript
/**
* Renders request body form inputs, JSON/XML textareas, MIME type dropdown, and schema preview panels for <api-request>.
*/
import { html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { live } from "lit/directives/live.js";
import { repeat } from "lit/directives/repeat.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { sanitizeHTML } from "../utils/sanitize.js";
import { marked } from "marked";
import {
schemaToAST,
getTypeInfo,
isBinaryFileField,
generateExample,
normalizeExamples,
standardizeExample,
EMPTY_ARRAY,
getMergedProperties,
getMergedRequired,
exampleLabel,
} from "~/utils/schema-utils";
import "~/components/schema-tree";
import "~/components/schema-table";
import "~/components/tag-input";
import {
exampleListTemplate,
constraintListTemplate,
examplesBesideConstraints,
renderExample,
} from "~/templates/request-params-template";
import { multipartPartContentType } from "~/utils/request-utils";
export function formDataParamAsObjectTemplate(
fieldName,
fieldSchema,
mimeType,
partContentType = "",
) {
// This template is used when form-data param should be send as a object (application/json, application/xml)
const formdataPartSchema = schemaToAST(fieldSchema);
const formdataPartExample = generateExample(
fieldSchema,
"json",
standardizeExample(fieldSchema.examples),
standardizeExample(fieldSchema.example),
this.callback === "true" || this.webhook === "true" ? true : false,
this.callback === "true" || this.webhook === "true" ? false : true,
"text",
false,
);
return html`
<div part="tab-panel" class="tab-panel row" style="min-height:220px; border-left: 6px solid var(--border); align-items: stretch;">
<div style="width:24px; background:var(--border)">
<div
class="row"
style="flex-direction:row-reverse; width:160px; height:24px; transform:rotate(270deg) translateX(-160px); transform-origin:top left; display:block;"
@click="${(e) => {
if (e.target.classList.contains("v-tab-btn")) {
const { tab } = e.target.dataset;
if (tab) {
const tabPanelEl = e.target.closest(".tab-panel");
const selectedTabBtnEl = tabPanelEl.querySelector(`.v-tab-btn[data-tab="${tab}"]`);
const otherTabBtnEl = [
...tabPanelEl.querySelectorAll(`.v-tab-btn:not([data-tab="${tab}"])`),
];
const selectedTabContentEl = tabPanelEl.querySelector(
`.tab-content[data-tab="${tab}"]`,
);
const otherTabContentEl = [
...tabPanelEl.querySelectorAll(`.tab-content:not([data-tab="${tab}"])`),
];
selectedTabBtnEl.classList.add("active");
selectedTabContentEl.style.display = "block";
otherTabBtnEl.forEach((el) => {
el.classList.remove("active");
});
otherTabContentEl.forEach((el) => {
el.style.display = "none";
});
}
}
if (e.target.tagName.toLowerCase() === "button") {
this.activeSchemaTab = e.target.dataset.tab;
}
}}"
>
<button class="v-tab-btn ${this.activeSchemaTab === "example" ? "active" : ""}" data-tab="example">EXAMPLE</button>
<button class="v-tab-btn ${this.activeSchemaTab !== "example" ? "active" : ""}" data-tab="schema">SCHEMA</button>
</div>
</div>
${html` <div
class="tab-content col"
data-tab="example"
style="display:${this.activeSchemaTab === "example" ? "block" : "none"}; padding-left:5px; width:100%"
>
<textarea
class="textarea"
part="textarea textarea-param"
style="width:100%; border:none; resize:vertical;"
data-array="false"
data-ptype="${mimeType.includes("form-urlencode") ? "form-urlencode" : "form-data"}"
data-pname="${fieldName}"
data-content-type="${partContentType}"
data-example="${formdataPartExample[0]?.exampleValue || ""}"
.value="${live(this.fillRequestFieldsWithExample === "true" ? formdataPartExample[0]?.exampleValue || "" : "")}"
spellcheck="false"
></textarea>
</div>`}
${html` <div
class="tab-content col"
data-tab="schema"
style="display:${this.activeSchemaTab !== "example" ? "block" : "none"}; padding-left:5px; width:100%;"
>
${
this.schemaStyle === "table"
? html`
<schema-table
.data="${formdataPartSchema}"
schema-expand-level="${this.schemaExpandLevel}"
schema-description-expanded="${this.schemaDescriptionExpanded}"
allow-schema-description-expand-toggle="${this.allowSchemaDescriptionExpandToggle}"
exportparts="schema-description:schema-description, schema-multiline-toggle:schema-multiline-toggle"
>
</schema-table>
`
: html`
<schema-tree
.data="${formdataPartSchema}"
schema-expand-level="${this.schemaExpandLevel}"
schema-description-expanded="${this.schemaDescriptionExpanded}"
allow-schema-description-expand-toggle="${this.allowSchemaDescriptionExpandToggle}"
exportparts="schema-description:schema-description, schema-multiline-toggle:schema-multiline-toggle"
>
</schema-tree>
`
}
</div>`}
</div>
`;
}
export function formDataTemplate(schema, mimeType, exampleValue = "", encoding = {}) {
const formFieldItems = [];
const properties = getMergedProperties(schema);
const required = getMergedRequired(schema);
if (Object.keys(properties).length > 0) {
for (const fieldName in properties) {
const fieldSchema = properties[fieldName];
if (fieldSchema.readOnly) {
continue;
}
const fieldExamples = fieldSchema.examples || fieldSchema.example || "";
const fieldType = fieldSchema.type;
// multipart parts are sent with this Content-Type (application/json for objects); empty means text/plain
const partContentType = mimeType.includes("form-data")
? multipartPartContentType(fieldSchema, encoding?.[fieldName])
: "";
const paramSchema = getTypeInfo(fieldSchema);
const example = normalizeExamples(
paramSchema.examples || paramSchema.example,
paramSchema.type,
);
const hasConstraint =
fieldType !== "object" &&
(paramSchema.default ||
paramSchema.constrain ||
paramSchema.allowedValues ||
paramSchema.pattern);
const inlineExamples = examplesBesideConstraints(
fieldSchema.description,
hasConstraint,
example.exampleList,
);
formFieldItems.push(
html` <li
class="param ${fieldSchema.deprecated ? "deprecated" : ""}"
part="param"
title="${fieldSchema.deprecated ? "Deprecated" : ""}"
>
<div class="param-label" part="param-label">
<div class="param-name ${fieldSchema.deprecated ? "deprecated" : ""}" part="param-name">
${fieldName}${required.includes(fieldName) ? html`<span class="param-required" title="Required">*</span>` : ""}
</div>
<div class="param-type" part="param-type">
${
paramSchema.type === "array" || paramSchema.type.split("┃").includes("array")
? `${paramSchema.arrayType || paramSchema.type}`
: `${paramSchema.format && !paramSchema.type.includes("┃") ? paramSchema.format : paramSchema.contentMediaType && !paramSchema.type.includes("┃") ? paramSchema.contentMediaType : paramSchema.type}`
}
</div>
</div>
${
fieldType !== "object" && this.allowTry !== "true"
? ""
: html`<div class="param-input" part="param-input">
${
fieldType === "array"
? isBinaryFileField(fieldSchema.items)
? html`
<div class="file-input-container col" style="align-items:flex-start; width:100%;">
${repeat(
this.fileInputKeys[fieldName] ?? [0],
(key) => key,
(key, index) => html`
<div class="input-set row" style="width:100%; margin-top: ${index > 0 ? "4px" : "0"};">
<input
type="file"
part="file-input"
style="width:100%"
data-pname="${fieldName}"
data-ptype="${mimeType.includes("form-urlencode") ? "form-urlencode" : "form-data"}"
data-array="false"
data-file-array="true"
/>
<button
class="file-input-remove-btn"
aria-label="Remove file"
title="Remove"
@click="${() => this.onRemoveFileInput(fieldName, key)}"
>
✕
</button>
</div>
`,
)}
<button
class="btn btn-primary btn-sm file-input-add-btn"
part="btn btn-fill"
@click="${() => this.onAddFileInput(fieldName)}"
>
ADD
</button>
</div>
`
: html`
<tag-input
style="width:100%"
data-ptype="${mimeType.includes("form-urlencode") ? "form-urlencode" : "form-data"}"
data-pname="${fieldName}"
data-content-type="${partContentType}"
data-example="${Array.isArray(fieldExamples) ? fieldExamples.join("~|~") : fieldExamples}"
data-array="true"
placeholder="add-multiple ↩"
.value="${
Array.isArray(fieldExamples)
? Array.isArray(fieldExamples[0])
? fieldExamples[0]
: fieldExamples
: EMPTY_ARRAY
}"
>
</tag-input>
`
: html` ${
fieldType === "object"
? formDataParamAsObjectTemplate.call(
this,
fieldName,
fieldSchema,
mimeType,
partContentType,
)
: html`
${
this.allowTry === "true"
? html`<input
.value="${this.fillRequestFieldsWithExample === "true" ? example.exampleVal : ""}"
spellcheck="false"
type="${isBinaryFileField(fieldSchema) ? "file" : fieldSchema.format === "password" ? "password" : "text"}"
part="textbox textbox-param"
style="width:100%"
data-ptype="${mimeType.includes("form-urlencode") ? "form-urlencode" : "form-data"}"
data-pname="${fieldName}"
data-content-type="${partContentType}"
data-example="${Array.isArray(fieldExamples) ? fieldExamples[0] : fieldExamples}"
data-array="false"
/>`
: ""
}
`
}`
}
</div>`
}
${
hasConstraint
? constraintListTemplate(
paramSchema,
paramSchema.allowedValues &&
String(paramSchema.allowedValues)
.split("┃")
.map(
(v) =>
html`<button
type="button"
part="anchor anchor-param-constraint"
class="value-chip"
?disabled="${this.allowTry !== "true"}"
data-type="${paramSchema.type === "array" ? paramSchema.type : "string"}"
data-enum="${v.trim()}"
@click="${(e) => {
const inputEl = e.target
.closest(".param-list")
.querySelector(`[data-pname="${fieldName}"]`);
if (inputEl) {
if (e.target.dataset.type === "array") {
inputEl.value = [e.target.dataset.enum];
} else {
inputEl.value = e.target.dataset.enum;
}
const requestPanelEl = this.getRequestPanel(e);
this.liveCURLSyntaxUpdate(requestPanelEl);
}
}}"
>
${v}
</button>`,
),
inlineExamples &&
example.exampleList.map((x) =>
renderExample.call(this, x, paramSchema.type, fieldName),
),
)
: ""
}
${
fieldType === "object"
? ""
: html`<div class="param-details" part="param-details">
${fieldSchema.description ? html`<div class="m-markdown-small">${unsafeHTML(sanitizeHTML(marked(fieldSchema.description)))}</div>` : ""}
${inlineExamples ? "" : exampleListTemplate.call(this, fieldName, paramSchema.type, example.exampleList)}
</div>`
}
</li>`,
);
}
return html`
<div class="param-list-container">
<ul
class="param-list"
part="param-list"
role="list"
@input="${(e) => {
const requestPanelEl = this.getRequestPanel(e);
this.liveCURLSyntaxUpdate(requestPanelEl);
}}"
@change="${(e) => {
const requestPanelEl = this.getRequestPanel(e);
this.liveCURLSyntaxUpdate(requestPanelEl);
}}"
>
${formFieldItems}
</ul>
</div>
`;
}
return html`
<textarea
class="textarea dynamic-form-param ${mimeType}"
part="textarea textarea-param"
spellcheck="false"
data-pname="dynamic-form"
data-ptype="${mimeType}"
.value="${live(exampleValue)}"
style="width:100%"
></textarea>
${schema.description ? html`<span class="m-markdown-small"> ${unsafeHTML(sanitizeHTML(marked(schema.description)))} </span>` : ""}
`;
}
export default function requestBodyTemplate() {
if (!this.request_body) {
return "";
}
if (Object.keys(this.request_body).length === 0) {
return "";
}
// Variable to store partial HTMLs
let reqBodyTypeSelectorHtml = "";
let reqBodyFileInputHtml = "";
let reqBodyFormHtml = "";
let reqBodySchemaHtml = "";
let reqBodyExampleHtml = "";
const requestBodyTypes = [];
const { content } = this.request_body;
for (const mimeType in content) {
requestBodyTypes.push({
mimeType,
schema: content[mimeType].schema,
example: content[mimeType].example,
examples: content[mimeType].examples,
encoding: content[mimeType].encoding,
});
// Fallback guard: If selectedRequestBodyType was cleared (e.g., after focused-mode navigation),
// automatically latch onto the first available MIME type (e.g., application/json).
if (!this.selectedRequestBodyType) {
this.selectedRequestBodyType = mimeType;
}
}
// MIME Type selector
reqBodyTypeSelectorHtml =
requestBodyTypes.length === 1
? ""
: html`
<select class="select-sm" aria-label="request body media type" @change="${(e) => this.onMimeTypeChange(e)}">
${requestBodyTypes.map(
(reqBody) => html`
<option value="${reqBody.mimeType}" ?selected="${reqBody.mimeType === this.selectedRequestBodyType}">
${reqBody.mimeType}
</option>
`,
)}
</select>
`;
// For Loop - Main
requestBodyTypes.forEach((reqBody) => {
let schemaAsObj;
let reqBodyExamples = [];
if (
this.selectedRequestBodyType.includes("json") ||
this.selectedRequestBodyType.includes("xml") ||
this.selectedRequestBodyType.includes("text") ||
this.selectedRequestBodyType.includes("jose")
) {
// Generate Example
if (reqBody.mimeType === this.selectedRequestBodyType) {
reqBodyExamples = generateExample(
reqBody.schema,
reqBody.mimeType,
standardizeExample(reqBody.examples),
standardizeExample(reqBody.example),
this.callback === "true" || this.webhook === "true" ? true : false,
this.callback === "true" || this.webhook === "true" ? false : true,
"text",
false,
);
if (!this.selectedRequestBodyExample) {
this.selectedRequestBodyExample =
reqBodyExamples.length > 0 ? reqBodyExamples[0].exampleId : "";
}
reqBodyExampleHtml = html`
${reqBodyExampleHtml}
<div class="example-panel">
${
reqBodyExamples.length === 1
? ""
: html`
<select
class="select-sm example-select"
aria-label="request body examples"
title="${exampleLabel(reqBodyExamples.find((v) => v.exampleId === this.selectedRequestBodyExample))}"
@change="${(e) => this.onSelectExample(e)}"
>
${reqBodyExamples.map(
(v) =>
html`<option value="${v.exampleId}" title="${exampleLabel(v)}" ?selected=${v.exampleId === this.selectedRequestBodyExample}>
${exampleLabel(v)}
</option>`,
)}
</select>
`
}
${reqBodyExamples
.filter((v) => v.exampleId === this.selectedRequestBodyExample)
.map(
(v) => html`
<div
class="example ${v.exampleId === this.selectedRequestBodyExample ? "example-selected" : ""}"
data-example="${v.exampleId}"
>
${reqBodyExamples.length === 1 && v.exampleSummary && v.exampleSummary.length > 80 ? html`<div style="padding: 4px 0">${v.exampleSummary}</div>` : ""}
${
v.exampleDescription
? html`<div class="m-markdown-small" style="padding: 4px 0">
${unsafeHTML(sanitizeHTML(marked(v.exampleDescription || "")))}
</div>`
: ""
}
<!-- This pre(hidden) is to store the original example value, this will remain unchanged when users switches from one example to another, its is used to populate the editable textarea -->
<pre
class="textarea is-hidden request-body-param ${reqBody.mimeType.substring(reqBody.mimeType.indexOf("/") + 1)}"
spellcheck="false"
data-ptype="${reqBody.mimeType}"
style="width:100%; resize:vertical; display:none"
>
${v.exampleFormat === "text" ? v.exampleValue : JSON.stringify(v.exampleValue, null, 2)}</pre>
<!-- this textarea is for user to edit the example -->
<textarea
class="textarea request-body-param-user-input"
part="textarea textarea-param"
spellcheck="false"
data-ptype="${reqBody.mimeType}"
data-example="${v.exampleFormat === "text" ? v.exampleValue : JSON.stringify(v.exampleValue, null, 2)}"
data-example-format="${v.exampleFormat}"
style="width:100%; resize:vertical;"
.value="${
this.fillRequestFieldsWithExample === "true"
? v.exampleFormat === "text"
? v.exampleValue
: JSON.stringify(v.exampleValue, null, 2)
: ""
}"
@input=${(e) => {
const requestPanelEl = this.getRequestPanel(e);
this.liveCURLSyntaxUpdate(requestPanelEl);
}}
@keydown=${(e) => {
if ((e.keyCode === 10 || e.keyCode === 13) && e.ctrlKey) {
return this.onTryClick(e);
}
}}
></textarea>
</div>
`,
)}
</div>
`;
}
} else if (
this.selectedRequestBodyType.includes("form-urlencoded") ||
this.selectedRequestBodyType.includes("form-data")
) {
if (reqBody.mimeType === this.selectedRequestBodyType) {
const ex = generateExample(
reqBody.schema,
reqBody.mimeType,
reqBody.examples,
reqBody.example,
this.callback === "true" || this.webhook === "true" ? true : false,
this.callback === "true" || this.webhook === "true" ? false : true,
"text",
false,
);
if (reqBody.schema) {
reqBodyFormHtml = formDataTemplate.call(
this,
reqBody.schema,
reqBody.mimeType,
ex[0] ? ex[0].exampleValue : "",
reqBody.encoding,
);
}
}
} else if (
/^audio\/|^image\/|^video\/|^font\/|tar$|zip$|7z$|rtf$|msword$|excel$|\/pdf$|\/octet-stream$/.test(
this.selectedRequestBodyType,
)
) {
if (reqBody.mimeType === this.selectedRequestBodyType) {
reqBodyFileInputHtml = html`
<input
id="input-request-body-param-file"
type="file"
part="file-input"
style="max-width:100%"
class="request-body-param-file small-font-size bold-text"
data-ptype="${reqBody.mimeType}"
spellcheck="false"
/>
`;
}
}
// Generate Schema
if (
reqBody.mimeType.includes("json") ||
reqBody.mimeType.includes("xml") ||
reqBody.mimeType.includes("text") ||
this.selectedRequestBodyType.includes("jose")
) {
schemaAsObj = schemaToAST(reqBody.schema);
if (this.schemaStyle === "table") {
reqBodySchemaHtml = html`
${reqBodySchemaHtml}
<schema-table
class="${reqBody.mimeType.substring(reqBody.mimeType.indexOf("/") + 1)}"
style="display: ${this.selectedRequestBodyType === reqBody.mimeType ? "block" : "none"};"
.data="${schemaAsObj}"
schema-expand-level="${this.schemaExpandLevel}"
schema-description-expanded="${this.schemaDescriptionExpanded}"
allow-schema-description-expand-toggle="${this.allowSchemaDescriptionExpandToggle}"
schema-hide-read-only="${this.schemaHideReadOnly}"
schema-hide-write-only="${this.schemaHideWriteOnly}"
exportparts="schema-description:schema-description, schema-multiline-toggle:schema-multiline-toggle"
>
</schema-table>
`;
} else if (this.schemaStyle === "tree") {
reqBodySchemaHtml = html`
${reqBodySchemaHtml}
<schema-tree
class="${reqBody.mimeType.substring(reqBody.mimeType.indexOf("/") + 1)}"
style="display: ${this.selectedRequestBodyType === reqBody.mimeType ? "block" : "none"};"
.data="${schemaAsObj}"
schema-expand-level="${this.schemaExpandLevel}"
schema-description-expanded="${this.schemaDescriptionExpanded}"
allow-schema-description-expand-toggle="${this.allowSchemaDescriptionExpandToggle}"
schema-hide-read-only="${this.schemaHideReadOnly}"
schema-hide-write-only="${this.schemaHideWriteOnly}"
exportparts="schema-description:schema-description, schema-multiline-toggle:schema-multiline-toggle"
>
</schema-tree>
`;
}
}
});
return html`
<div class="request-body-container" data-selected-request-body-type="${ifDefined(this.selectedRequestBodyType)}">
<div class="table-title top-gap row" style="justify-content:space-between; align-items:center;">
<h5 class="rapidoc-heading label-heading" part="heading heading-5">
Request body ${this.request_body.required ? html`<span class="mono-font" style="color:var(--destructive)">*</span>` : ""}
<span style="font-weight:normal; margin-left:5px; text-transform:none"> ${this.selectedRequestBodyType}</span>
</h5>
${reqBodyTypeSelectorHtml}
</div>
${
this.request_body.description
? html`<div class="m-markdown" style="margin-bottom:12px">
${unsafeHTML(sanitizeHTML(marked(this.request_body.description)))}
</div>`
: ""
}
${
this.selectedRequestBodyType.includes("json") ||
this.selectedRequestBodyType.includes("xml") ||
this.selectedRequestBodyType.includes("text") ||
this.selectedRequestBodyType.includes("jose")
? html` <div part="tab-panel" class="tab-panel col">
<div
part="tab-btn-row"
class="tab-buttons row"
@click="${(e) => {
if (e.target.tagName.toLowerCase() === "button") {
this.activeSchemaTab = e.target.dataset.tab;
}
}}"
>
<button part="tab-btn" class="tab-btn ${this.activeSchemaTab === "example" ? "active" : ""}" data-tab="example">
EXAMPLE
</button>
<button part="tab-btn" class="tab-btn ${this.activeSchemaTab !== "example" ? "active" : ""}" data-tab="schema">
SCHEMA
</button>
</div>
${html`<div
part="tab-content"
class="tab-content col"
style="display:${this.activeSchemaTab === "example" ? "block" : "none"};"
>
${reqBodyExampleHtml}
</div>`}
${html`<div
part="tab-content"
class="tab-content col"
style="display:${this.activeSchemaTab === "example" ? "none" : "block"};"
>
${reqBodySchemaHtml}
</div>`}
</div>`
: html` ${reqBodyFileInputHtml} ${reqBodyFormHtml}`
}
</div>
`;
}