UNPKG

rapidoc

Version:

RapiDoc - Open API spec viewer with built in console

515 lines (498 loc) • 21.9 kB
import { LitElement, html, css } from "lit"; import { customElement, property } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { sanitizeHTML } from "../utils/sanitize.js"; import { marked } from "marked"; import { prepareResponseDefinitions } from "../utils/response-utils.js"; import { exampleLabel } from "~/utils/schema-utils"; import FontStyles from "~/styles/font-styles"; import FlexStyles from "~/styles/flex-styles"; import ParamListStyles from "~/styles/param-list-styles"; import InputStyles from "~/styles/input-styles"; import TabStyles from "~/styles/tab-styles"; import BorderStyles from "~/styles/border-styles"; import CustomStyles from "~/styles/custom-styles"; import "~/components/json-tree"; import "~/components/schema-tree"; import "~/components/schema-table"; @customElement("api-response") export default class ApiResponse extends LitElement { @property({ type: Object }) config: any; @property({ type: String }) callback = ""; @property({ type: String }) webhook = ""; @property({ type: Object }) responses: any; @property({ type: Object }) parser: any; @property({ type: String, attribute: "schema-style" }) schemaStyle = ""; @property({ type: String, attribute: "render-style" }) renderStyle = ""; @property({ type: String, attribute: "selected-status" }) selectedStatus = ""; @property({ type: String, attribute: "selected-mime-type" }) selectedMimeType = ""; @property({ type: String, attribute: "active-schema-tab" }) activeSchemaTab = "schema"; @property({ type: Number, attribute: "schema-expand-level" }) schemaExpandLevel = 99999; @property({ type: String, attribute: "schema-description-expanded" }) schemaDescriptionExpanded = "false"; @property({ type: String, attribute: "allow-schema-description-expand-toggle" }) allowSchemaDescriptionExpandToggle = "false"; @property({ type: String, attribute: "schema-hide-read-only" }) schemaHideReadOnly = "false"; @property({ type: String, attribute: "schema-hide-write-only" }) schemaHideWriteOnly = "true"; defaultStatus = ""; defaultMimeType = ""; headersForEachRespStatus: any = {}; mimeResponsesForEachStatus: any = {}; constructor() { super(); this.selectedStatus = ""; this.defaultStatus = ""; this.defaultMimeType = ""; this.headersForEachRespStatus = {}; this.mimeResponsesForEachStatus = {}; this.activeSchemaTab = "schema"; } static get styles() { return [ FontStyles, FlexStyles, TabStyles, ParamListStyles, InputStyles, BorderStyles, css` .resp-headers-list { margin-bottom: 16px; } :where(button, input[type='checkbox'], [tabindex='0']):focus-visible { box-shadow: var(--focus-shadow); } :where(input[type='text'], input[type='password'], select, textarea):focus-visible { border-color: var(--primary); } .resp-head { vertical-align: middle; padding: 16px 0 8px; &.divider { border-top: 1px solid var(--border); margin-top: 10px; } } .resp-status { font-weight: bold; font-size: calc(var(--font-size-small) + 1px); } .resp-descr { font-size: var(--font-size-description); color: var(--muted-foreground); text-align: left; } .top-gap { margin-top: 16px; } .example-panel { font-size: var(--font-size-small); margin: 0; } /* Response codes: a segmented control (one choice), the selected code tinted with the primary color */ .status-group { display: inline-flex; flex-wrap: wrap; margin-top: 8px; } .status-btn { margin: 0 0 0 -1px; border-color: var(--input); border-radius: 0; background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--muted-foreground); &:first-child { margin-left: 0; border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } &:last-child { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } &:hover { background: var(--muted); color: var(--foreground); } &[aria-pressed='true'] { position: relative; z-index: 1; border-color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); } &:focus-visible { z-index: 2; } } `, CustomStyles, ]; } willUpdate(changedProperties) { super.willUpdate?.(changedProperties); // Sync configuration options passed down from parent <rapi-doc> if (this.config && changedProperties.has("config")) { if (this.config.renderStyle) this.renderStyle = this.config.renderStyle; if (this.config.schemaStyle) this.schemaStyle = this.config.schemaStyle; if (this.config.schemaExpandLevel !== undefined) this.schemaExpandLevel = this.config.schemaExpandLevel; if (this.config.schemaDescriptionExpanded !== undefined) this.schemaDescriptionExpanded = this.config.schemaDescriptionExpanded; if (this.config.allowSchemaDescriptionExpandToggle !== undefined) this.allowSchemaDescriptionExpandToggle = this.config.allowSchemaDescriptionExpandToggle; // In responses, readOnly fields are shown by default ('false') while writeOnly fields are hidden ('true'). // For webhooks, this behavior is inverted unless configured to 'never'. if (this.config.schemaHideReadOnly !== undefined) this.schemaHideReadOnly = this.config.schemaHideReadOnly === "never" ? "false" : this.webhook === "true" ? "true" : "false"; if (this.config.schemaHideWriteOnly !== undefined) this.schemaHideWriteOnly = this.config.schemaHideWriteOnly === "never" ? "false" : this.webhook === "true" ? "false" : "true"; if (this.config.defaultSchemaTab) this.activeSchemaTab = this.config.defaultSchemaTab; } // Parse response definitions, headers, examples, and AST schemas when responses change if ( changedProperties.has("responses") || changedProperties.has("callback") || changedProperties.has("webhook") ) { if (!this.responses || typeof this.responses !== "object") { return; } const defs = prepareResponseDefinitions(this.responses, { callback: this.callback, webhook: this.webhook, }); this.headersForEachRespStatus = defs.headersForEachRespStatus; this.mimeResponsesForEachStatus = defs.mimeResponsesForEachStatus; this.defaultStatus = defs.defaultStatus; this.defaultMimeType = defs.defaultMimeType; // Default to primary status code (e.g. '200') if none is currently selected or if the old status doesn't exist on this response if (!this.selectedStatus || !this.responses?.[this.selectedStatus]) { this.selectedStatus = defs.defaultStatus; } if (!this.selectedMimeType) { this.selectedMimeType = defs.defaultMimeType; } } } render() { return html`<div class="col regular-font response-panel ${this.renderStyle}-mode"> ${ this.callback === "true" ? html`<h6 class="rapidoc-heading label-heading tiny-title" part="section-response-title heading heading-6"> Callback response </h6>` : html`<h4 class="rapidoc-heading label-heading req-res-title" part="section-response-title heading heading-4">Response</h4>` } <div>${this.responseTemplate()}</div> </div> `; } // Called by rapi-doc after navigating in focused mode. // In focused mode, the single <api-response> element instance is reused across operations. // We reset selections back to the default status code and MIME type of the active endpoint, // preventing user selections from a prior endpoint (e.g., status 400) from carrying over or hiding panels. resetSelection() { this.selectedStatus = this.defaultStatus || (this.responses ? Object.keys(this.responses).find((k) => k !== "$$ref") || "" : ""); if (this.selectedStatus && this.mimeResponsesForEachStatus?.[this.selectedStatus]) { this.selectedMimeType = Object.keys(this.mimeResponsesForEachStatus[this.selectedStatus])[0] || ""; } else { this.selectedMimeType = this.defaultMimeType || ""; } } responseTemplate() { if (!this.responses) { return ""; } // Fallback guard: Ensure selectedStatus and selectedMimeType always point to valid options // for this endpoint, even if resetSelection was called or responses have not re-rendered yet. if (!this.selectedStatus || !this.responses[this.selectedStatus]) { this.selectedStatus = this.defaultStatus || Object.keys(this.responses).find((k) => k !== "$$ref") || Object.keys(this.responses)[0] || ""; } if ( !this.selectedMimeType && this.selectedStatus && this.mimeResponsesForEachStatus?.[this.selectedStatus] ) { this.selectedMimeType = Object.keys(this.mimeResponsesForEachStatus[this.selectedStatus])[0] || this.defaultMimeType || ""; } return html` ${ Object.keys(this.responses).length > 1 ? html`<div class="status-group" role="group" aria-label="Response status"> ${Object.keys(this.responses).map( (respStatus) => html` ${ respStatus === "$$ref" // Ignore '$$ref' if present from legacy reference parsing ? "" : html`<button @click="${() => { this.selectedStatus = respStatus; if ( this.responses[respStatus].content && Object.keys(this.responses[respStatus].content)[0] ) { this.selectedMimeType = Object.keys( this.responses[respStatus].content, )[0]; } else { this.selectedMimeType = undefined; } }}" class="btn btn-sm status-btn" aria-pressed="${this.selectedStatus === respStatus ? "true" : "false"}" part="btn ${ this.selectedStatus === respStatus ? "btn-response-status btn-selected-response-status" : " btn-response-status" }" > ${respStatus} </button>` }`, )} </div>` : html`<span>${Object.keys(this.responses)[0]}</span>` } ${Object.keys(this.responses).map( (status) => html`<div style="display: ${status === this.selectedStatus ? "block" : "none"}"> <div class="top-gap"> <span class="resp-descr m-markdown">${unsafeHTML(sanitizeHTML(marked(this.responses[status]?.description || "")))}</span> ${ this.headersForEachRespStatus[status] && this.headersForEachRespStatus[status]?.length > 0 ? html`${this.responseHeaderListTemplate(this.headersForEachRespStatus[status])}` : "" } </div> ${ !this.mimeResponsesForEachStatus[status] || Object.keys(this.mimeResponsesForEachStatus[status]).length === 0 ? "" : 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> ${ Object.keys(this.mimeResponsesForEachStatus[status]).length === 1 ? html`<span class="tab-bar-end">${Object.keys(this.mimeResponsesForEachStatus[status])[0]}</span>` : html`${this.mimeTypeDropdownTemplate(Object.keys(this.mimeResponsesForEachStatus[status]))}` } </div> ${ this.activeSchemaTab === "example" ? html`<div part="tab-content" class="tab-content col"> ${this.mimeExampleTemplate(this.mimeResponsesForEachStatus[status][this.selectedMimeType])} </div>` : html`<div part="tab-content" class="tab-content col"> ${this.mimeSchemaTemplate(this.mimeResponsesForEachStatus[status][this.selectedMimeType])} </div>` } </div> ` } </div>`, )} `; } responseHeaderListTemplate(respHeaders) { return html`<h5 class="rapidoc-heading label-heading resp-headers" part="heading heading-5">Response headers</h5> <div class="param-list-container resp-headers-list"> <ul class="param-list" part="param-list" role="list"> ${respHeaders.map( (v) => html`<li class="param" part="param"> <div class="param-label" part="param-label"> <div class="param-name" part="param-name">${v.name || ""}</div> ${v.schema?.type ? html`<div class="param-type" part="param-type">${v.schema.type}</div>` : ""} </div> ${ v.schema?.example ? html`<dl class="param-constraint" part="param-constraint"> <dt class="param-constraint-label" part="param-constraint-label">Example:</dt> <dd>${v.schema.example}</dd> </dl>` : "" } <div class="param-details" part="param-details"> ${v.description ? html`<div class="m-markdown-small">${unsafeHTML(sanitizeHTML(marked(v.description)))}</div>` : ""} </div> </li>`, )} </ul> </div>`; } mimeTypeDropdownTemplate(mimeTypes) { return html`<select aria-label="mime types" class="select-sm tab-bar-end" @change="${(e) => { this.selectedMimeType = e.target.value; }}" > ${mimeTypes.map( (mimeType) => html`<option value="${mimeType}" ?selected="${mimeType === this.selectedMimeType}">${mimeType}</option>`, )} </select>`; } onSelectExample(e) { const exampleContainerEl = e.target.closest(".example-panel"); const exampleEls = [...exampleContainerEl.querySelectorAll(".example")]; exampleEls.forEach((v) => { v.style.display = v.dataset.example === e.target.value ? "block" : "none"; }); } mimeExampleTemplate(mimeRespDetails) { if (!mimeRespDetails || !mimeRespDetails.examples || mimeRespDetails.examples.length === 0) { return html` <pre style="color:var(--destructive)" class="example-panel"> No example provided </pre> `; } return html` ${ mimeRespDetails.examples.length === 1 ? html` ${ mimeRespDetails.examples[0].exampleFormat === "json" ? html` ${ mimeRespDetails.examples[0].exampleSummary && mimeRespDetails.examples[0].exampleSummary.length > 80 ? html`<div style="padding: 4px 0">${mimeRespDetails.examples[0].exampleSummary}</div>` : "" } ${ mimeRespDetails.examples[0].exampleDescription ? html`<div class="m-markdown-small" style="padding: 4px 0"> ${unsafeHTML(sanitizeHTML(marked(mimeRespDetails.examples[0].exampleDescription || "")))} </div>` : "" } <json-tree render-style="${this.renderStyle}" .data="${mimeRespDetails.examples[0].exampleValue}" class="example-panel" exportparts="btn:btn, btn-outline:btn-outline, btn-copy:btn-copy" ></json-tree>` : html` ${ mimeRespDetails.examples[0].exampleSummary && mimeRespDetails.examples[0].exampleSummary.length > 80 ? html`<div style="padding: 4px 0">${mimeRespDetails.examples[0].exampleSummary}</div>` : "" } ${ mimeRespDetails.examples[0].exampleDescription ? html`<div class="m-markdown-small" style="padding: 4px 0"> ${unsafeHTML(sanitizeHTML(marked(mimeRespDetails.examples[0].exampleDescription || "")))} </div>` : "" } <pre class="example-panel">${mimeRespDetails.examples[0].exampleValue}</pre> ` }` : html` <div class="example-panel"> <select aria-label="response examples" class="select-sm example-select" title="${exampleLabel(mimeRespDetails.examples.find((v) => v.exampleId === mimeRespDetails.selectedExample))}" @change="${(e) => this.onSelectExample(e)}" > ${mimeRespDetails.examples.map( (v) => html`<option value="${v.exampleId}" title="${exampleLabel(v)}" ?selected=${v.exampleId === mimeRespDetails.selectedExample}> ${exampleLabel(v)} </option>`, )} </select> ${mimeRespDetails.examples.map( (v) => html` <div class="example" data-example="${v.exampleId}" style="display: ${v.exampleId === mimeRespDetails.selectedExample ? "block" : "none"}" > ${ v.exampleDescription ? html`<div class="m-markdown-small" style="padding: 4px 0"> ${unsafeHTML(sanitizeHTML(marked(v.exampleDescription || "")))} </div>` : "" } ${ v.exampleFormat === "json" ? html`<json-tree render-style="${this.renderStyle}" .data="${v.exampleValue}" exportparts="btn:btn, btn-outline:btn-outline, btn-copy:btn-copy" ></json-tree>` : html`<pre>${v.exampleValue}</pre>` } </div> `, )} </div> ` } `; } mimeSchemaTemplate(mimeRespDetails) { if (!mimeRespDetails) { return html` <pre style="color:var(--destructive)" class="example-panel"> Schema not found</pre> `; } return html` ${ this.schemaStyle === "table" ? html` <schema-table .data="${mimeRespDetails.schemaTree}" 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> ` : html`<schema-tree .data="${mimeRespDetails.schemaTree}" 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>` }`; } }