UNPKG

rapidoc

Version:

RapiDoc - Open API spec viewer with built in console

674 lines (661 loc) • 29 kB
/** * 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)}" > &#x2715; </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 &#x21a9;" .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> `; }