From 7eb62cd29d1018fbf9b43b673828f9959b4a7139 Mon Sep 17 00:00:00 2001 From: Matt Nemitz Date: Fri, 25 Jul 2025 13:10:14 +0100 Subject: [PATCH 1/8] wip --- spec/batch.yaml | 2 + spec/realtime.yaml | 59 ++++ .../FormTextInput/_FormTextInput.scss | 34 ++ src/theme/ApiExplorer/FormTextInput/index.tsx | 113 +++++++ .../ParamFormItems/ParamBooleanFormItem.tsx | 48 +++ .../ParamFormItems/ParamTextFormItem.tsx | 38 +++ src/theme/ApiExplorer/Request/_Request.scss | 129 ++++++++ src/theme/ApiExplorer/Request/index.tsx | 302 ++++++++++++++++++ src/theme/ApiExplorer/Request/makeRequest.ts | 247 ++++++++++++++ 9 files changed, 972 insertions(+) create mode 100644 src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss create mode 100644 src/theme/ApiExplorer/FormTextInput/index.tsx create mode 100644 src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx create mode 100644 src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx create mode 100644 src/theme/ApiExplorer/Request/_Request.scss create mode 100644 src/theme/ApiExplorer/Request/index.tsx create mode 100644 src/theme/ApiExplorer/Request/makeRequest.ts diff --git a/spec/batch.yaml b/spec/batch.yaml index 84771c8d..0823a6af 100644 --- a/spec/batch.yaml +++ b/spec/batch.yaml @@ -22,6 +22,8 @@ parameters: name: Authorization in: header description: Customer API token + pattern: "^Bearer .+$" + x-docusaurus-example-prefix: "Bearer " required: true type: string EARTag: diff --git a/spec/realtime.yaml b/spec/realtime.yaml index 780eb587..80fc74de 100644 --- a/spec/realtime.yaml +++ b/spec/realtime.yaml @@ -746,6 +746,65 @@ components: - duration_limit_exceeded ErrorTypeEnum: type: string + # TODO if OpenAPI/AsyncAPI ever adds enum descriptors, we can move this description there + # https://github.com/OAI/OpenAPI-Specification/issues/348 + # In the meantime we just have a long description of the different enum variants + description: >- + The following are the possible error types: + + + | Error Type | Description | + + | --- | --- | + + | `invalid_message` | The message received was not understood. | + + | `invalid_model` | Unable to use the model for the recognition. This can happen if the language is not supported at all, or is not available for the user. | + + | `invalid_config` | The config received contains some wrong/unsupported fields, or too many translation target languages were requested. | + + | `invalid_audio_type` | Audio type is not supported, is deprecated, or the audio_type is malformed. | + + | `invalid_output_format` | Output format is not supported, is deprecated, or the output_format is malformed. | + + | `not_authorised` | User was not recognised, or the API key provided is not valid. | + + | `insufficient_funds` | User doesn't have enough credits or any other reason preventing the user to be charged for the job properly. | + + | `not_allowed` | User is not allowed to use this message (is not allowed to perform the action the message would invoke). | + + | `job_error` | Unable to do any work on this job, the server might have timed out etc. | + + | `data_error` | Unable to accept the data specified - usually because there is too much data being sent at once | + + | `buffer_error` | Unable to fit the data in a corresponding buffer. This can happen for clients sending the input data faster than real-time. | + + | `protocol_error` | Message received was syntactically correct, but could not be accepted due to protocol limitations. This is usually caused by messages sent in the wrong order. | + + | `quota_exceeded` | Maximum number of concurrent connections allowed for the contract has been reached | + + | `timelimit_exceeded` | Usage quota for the contract has been reached | + + | `idle_timeout` | Idle duration limit was reached (no audio data sent within the last hour), a closing handshake with code 1008 follows this in-band error. | + + | `session_timeout` | Max session duration was reached (maximum session duration of 48 hours), a closing handshake with code 1008 follows this in-band error. | + + | `session_transfer` | An error while transferring session to another backend with the reason: Session transfer failed. This may occur when moving sessions due to backend maintenance operations or migration from a faulty backend. | + + | `unknown_error` | An error that did not fit any of the types above. | + + | `quota_exceeded` | Maximum number of concurrent connections allowed for the contract has been reached | + + | `timelimit_exceeded` | Usage quota for the contract has been reached | + + | `idle_timeout` | Idle duration limit was reached (no audio data sent within the last hour), a closing handshake with code 1008 follows this in-band error. + + | `session_timeout` | Max session duration was reached (maximum session duration of 48 hours), a closing handshake with code 1008 follows this in-band error. + + | `session_transfer` | An error while transferring session to another backend with the reason: Session transfer failed. This may occur when moving sessions due to backend maintenance operations or migration from a faulty backend. + + | `unknown_error` | An error that did not fit any of the types above. + enum: - invalid_message - invalid_model diff --git a/src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss b/src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss new file mode 100644 index 00000000..8c02e92a --- /dev/null +++ b/src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss @@ -0,0 +1,34 @@ +.openapi-explorer__form-item-input { + margin-top: calc(var(--ifm-pre-padding) / 2); + background-color: var(--openapi-input-background); + border: 1px solid transparent; + outline: none; + width: 100%; + color: var(--ifm-pre-color); + padding: var(--openapi-explorer-padding-input); + border-radius: 4px; + + &:hover { + border: 1px solid var(--ifm-toc-border-color); + } + + &:focus { + border: 1px solid var(--ifm-color-primary); + box-shadow: none; + } + + &.error { + border: 1px solid var(--openapi-required); + } +} + +.openapi-explorer__input-error { + font-size: var(--openapi-explorer-font-size-input); + color: var(--openapi-required); + padding-top: var(--openapi-explorer-padding-input); + + &::before { + display: inline; + content: "⚠ "; + } +} diff --git a/src/theme/ApiExplorer/FormTextInput/index.tsx b/src/theme/ApiExplorer/FormTextInput/index.tsx new file mode 100644 index 00000000..fa6417af --- /dev/null +++ b/src/theme/ApiExplorer/FormTextInput/index.tsx @@ -0,0 +1,113 @@ +import type React from "react"; +import { forwardRef } from "react"; + +import { ErrorMessage } from "@hookform/error-message"; +import clsx from "clsx"; +import { useFormContext } from "react-hook-form"; +import { TextField, Text } from "@radix-ui/themes"; + +export interface Props { + isRequired?: boolean; + paramName?: string; + value?: string; + placeholder?: string; + password?: boolean; + onChange?: React.ChangeEventHandler; + prefix?: string; +} + +const FormTextInput = forwardRef( + ( + { + isRequired, + value, + placeholder, + password, + onChange, + paramName, + prefix, + ...props + }: Props, + ref, + ) => { + placeholder = placeholder?.split("\n")[0]; + + const { + register, + formState: { errors }, + } = useFormContext(); + + const showErrorMessage = errors?.[paramName]?.message; + + return ( + <> + + + {prefix} + + + + {showErrorMessage && ( + ( +
{message}
+ )} + /> + )} + + ); + // return ( + // <> + // {paramName ? ( + // + // ) : ( + // + // )} + // {showErrorMessage && ( + // ( + //
{message}
+ // )} + // /> + // )} + // + // ); + }, +); + +export default FormTextInput; diff --git a/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx new file mode 100644 index 00000000..96cabbfa --- /dev/null +++ b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import { ErrorMessage } from "@hookform/error-message"; +import FormSelect from "@theme/ApiExplorer/FormSelect"; +import { Param, setParam } from "@theme/ApiExplorer/ParamOptions/slice"; +import { useTypedDispatch } from "@theme/ApiItem/hooks"; +import { Controller, useFormContext } from "react-hook-form"; +import { SegmentedControl } from "@radix-ui/themes"; + +export interface ParamProps { + param: Param; +} + +export default function ParamBooleanFormItem({ param }: ParamProps) { + const dispatch = useTypedDispatch(); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const showErrorMessage = errors?.paramBoolean; + + return ( + <> + ( + + true + false + + )} + /> + {showErrorMessage && ( + ( +
{message}
+ )} + /> + )} + + ); +} diff --git a/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx new file mode 100644 index 00000000..02334960 --- /dev/null +++ b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import FormTextInput from "@theme/ApiExplorer/FormTextInput"; +import { Param, setParam } from "@theme/ApiExplorer/ParamOptions/slice"; +import { useTypedDispatch } from "@theme/ApiItem/hooks"; +import { Box, Code, Flex } from "@radix-ui/themes"; + +export interface ParamProps { + param: Param; +} + +export default function ParamTextFormItem(props: ParamProps) { + const { param } = props; + const prefix = param["x-docusaurus-example-prefix"]; + + const dispatch = useTypedDispatch(); + return ( + + ) => + dispatch( + setParam({ + ...param, + value: + param.in === "path" || param.in === "query" + ? `${prefix}${e.target.value.replace(/\s/g, "%20")}` + : `${prefix}${e.target.value}`, + }), + ) + } + /> + + ); +} diff --git a/src/theme/ApiExplorer/Request/_Request.scss b/src/theme/ApiExplorer/Request/_Request.scss new file mode 100644 index 00000000..8b1d135d --- /dev/null +++ b/src/theme/ApiExplorer/Request/_Request.scss @@ -0,0 +1,129 @@ +.openapi-explorer__request-form { + background-color: var(--ifm-pre-background); + border-radius: var(--openapi-card-border-radius); + border: 1px solid var(--openapi-explorer-border-color); + box-shadow: + 0 2px 3px hsla(222, 8%, 43%, 0.1), + 0 8px 16px -10px hsla(222, 8%, 43%, 0.2); + color: var(--ifm-pre-color); + line-height: var(--ifm-pre-line-height); + margin-bottom: var(--ifm-spacing-vertical); + margin-top: 0; + overflow: auto; + transition: 300ms; + + /* hack for view calculation when monaco is hidden */ + position: relative; + + &:empty { + display: none; + } + + &:hover { + box-shadow: + 0 0 0 2px rgba(38, 53, 61, 0.15), + 0 2px 3px hsla(222, 8%, 43%, 0.15), + 0 16px 16px -10px hsla(222, 8%, 43%, 0.2); + } + + .required { + font-size: var(--ifm-code-font-size); + color: var(--openapi-required); + + &.request-body { + padding-left: 0.25rem; + } + } +} + +.openapi-explorer__request-header-container { + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--openapi-explorer-border-color); + margin: 0; + padding: 0.75rem var(--ifm-pre-padding); + text-transform: uppercase; + font-size: 12px; + font-weight: bold; +} + +.openapi-explorer__expand-details-btn { + &:hover { + cursor: pointer; + } +} + +.openapi-explorer__details-outer-container { + padding: 1rem; +} + +.openapi-explorer__details-container[open] { + .openapi-explorer__details-summary::before { + transform: rotate(180deg); + margin-top: 0.25rem; + } +} + +.openapi-explorer__details-summary { + display: inline-flex; + align-items: center; + padding: 0.35rem 0; + font-size: 14px; + list-style: none; + + &:hover { + cursor: pointer; + } + + &::-webkit-details-marker { + display: none; + } + + &::before { + margin-right: 0.25rem; + margin-bottom: 0.25rem; + margin-top: 0.25rem; + background-image: var(--openapi-explorer-caret-bg); + border: none !important; + transform: rotate(90deg); + content: ""; + height: 1rem; + width: 1rem; + } +} + +.openapi-explorer__request-btn { + border: none; + border-radius: var(--ifm-global-radius); + padding: 0.5rem 1rem; + margin-top: 1rem; + background-color: var(--ifm-color-primary-light); + text-transform: uppercase; + font-weight: bold; + font-size: 12px; + color: white; + cursor: pointer; + transition: 300ms; + + &:hover { + background-color: var(--ifm-color-primary-lightest); + } + + &:active { + background-color: var(--ifm-color-primary-light); + } +} + +.openapi-security__summary-container { + background: var(--ifm-pre-background); + border-radius: var(--ifm-pre-border-radius); +} + +// Prevent auto zoom on mobile iOS devices when focusing on input elmenents +@media screen and (-webkit-min-device-pixel-ratio: 0) and (max-device-width: 1024px) { + .prism-code, + select, + input { + font-size: 1rem; + } +} diff --git a/src/theme/ApiExplorer/Request/index.tsx b/src/theme/ApiExplorer/Request/index.tsx new file mode 100644 index 00000000..26e4e901 --- /dev/null +++ b/src/theme/ApiExplorer/Request/index.tsx @@ -0,0 +1,302 @@ +// @ts-nocheck +import React, { useState } from "react"; + +import { useDoc } from "@docusaurus/plugin-content-docs/client"; +import Accept from "@theme/ApiExplorer/Accept"; +import Authorization from "@theme/ApiExplorer/Authorization"; +import Body from "@theme/ApiExplorer/Body"; +import buildPostmanRequest from "@theme/ApiExplorer/buildPostmanRequest"; +import ContentType from "@theme/ApiExplorer/ContentType"; +import ParamOptions from "@theme/ApiExplorer/ParamOptions"; +import { + setResponse, + setCode, + clearCode, + setHeaders, + clearHeaders, +} from "@theme/ApiExplorer/Response/slice"; +import Server from "@theme/ApiExplorer/Server"; +import { useTypedDispatch, useTypedSelector } from "@theme/ApiItem/hooks"; +import { ParameterObject } from "docusaurus-plugin-openapi-docs/src/openapi/types"; +import { ApiItem } from "docusaurus-plugin-openapi-docs/src/types"; +import sdk from "postman-collection"; +import { FormProvider, useForm } from "react-hook-form"; + +import makeRequest from "./makeRequest"; + +function Request({ item }: { item: ApiItem }) { + const postman = new sdk.Request(item.postman); + const metadata = useDoc(); + const { proxy, hide_send_button: hideSendButton } = metadata.frontMatter; + + const pathParams = useTypedSelector((state: any) => state.params.path); + const queryParams = useTypedSelector((state: any) => state.params.query); + const cookieParams = useTypedSelector((state: any) => state.params.cookie); + const contentType = useTypedSelector((state: any) => state.contentType.value); + const headerParams = useTypedSelector((state: any) => state.params.header); + const body = useTypedSelector((state: any) => state.body); + const accept = useTypedSelector((state: any) => state.accept.value); + const acceptOptions = useTypedDispatch((state: any) => state.accept.options); + const authSelected = useTypedSelector((state: any) => state.auth.selected); + const server = useTypedSelector((state: any) => state.server.value); + const serverOptions = useTypedSelector((state: any) => state.server.options); + const auth = useTypedSelector((state: any) => state.auth); + const dispatch = useTypedDispatch(); + + const [expandAccept, setExpandAccept] = useState(true); + const [expandAuth, setExpandAuth] = useState(true); + const [expandBody, setExpandBody] = useState(true); + const [expandParams, setExpandParams] = useState(true); + const [expandServer, setExpandServer] = useState(true); + + const allParams = [ + ...pathParams, + ...queryParams, + ...cookieParams, + ...headerParams, + ]; + + const postmanRequest = buildPostmanRequest(postman, { + queryParams, + pathParams, + cookieParams, + contentType, + accept, + headerParams, + body, + server, + auth, + }); + + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const paramsObject = { + path: [] as ParameterObject[], + query: [] as ParameterObject[], + header: [] as ParameterObject[], + cookie: [] as ParameterObject[], + }; + + item.parameters?.forEach( + (param: { in: "path" | "query" | "header" | "cookie" }) => { + const paramType = param.in; + const paramsArray: ParameterObject[] = paramsObject[paramType]; + paramsArray.push(param as ParameterObject); + } + ); + + const methods = useForm({ shouldFocusError: false }); + + const handleEventStream = async (res) => { + res.headers && dispatch(setHeaders(Object.fromEntries(res.headers))); + dispatch(setCode(res.status)); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + dispatch(setResponse(result)); + } + }; + + const handleResponse = async (res) => { + dispatch(setResponse(await res.text())); + dispatch(setCode(res.status)); + res.headers && dispatch(setHeaders(Object.fromEntries(res.headers))); + }; + + const onSubmit = async (data) => { + dispatch(setResponse("Fetching...")); + try { + await delay(1200); + const res = await makeRequest(postmanRequest, proxy, body); + if (res.headers.get("content-type")?.includes("text/event-stream")) { + await handleEventStream(res); + } else { + await handleResponse(res); + } + } catch (e: any) { + console.log(e); + dispatch(setResponse("Connection failed")); + dispatch(clearCode()); + dispatch(clearHeaders()); + } + }; + + const showServerOptions = serverOptions.length > 0; + const showAcceptOptions = acceptOptions.length > 1; + const showRequestBody = contentType !== undefined; + const showRequestButton = item.servers && !hideSendButton; + const showAuth = authSelected !== undefined; + const showParams = allParams.length > 0; + const requestBodyRequired = item.requestBody?.required; + + if ( + !showAcceptOptions && + !showAuth && + !showParams && + !showRequestBody && + !showServerOptions + ) { + return null; + } + + const expandAllDetails = () => { + setExpandAccept(true); + setExpandAuth(true); + setExpandBody(true); + setExpandParams(true); + setExpandServer(true); + }; + + const collapseAllDetails = () => { + setExpandAccept(false); + setExpandAuth(false); + setExpandBody(false); + setExpandParams(false); + setExpandServer(false); + }; + + const allDetailsExpanded = + expandParams && expandBody && expandServer && expandAuth && expandAccept; + + return ( + +
+
+ Request + {allDetailsExpanded ? ( + + Collapse all + + ) : ( + + Expand all + + )} +
+
+ {showServerOptions && item.method !== "event" && ( +
+ { + e.preventDefault(); + setExpandServer(!expandServer); + }} + > + Base URL + + +
+ )} + {showAuth && ( +
+ { + e.preventDefault(); + setExpandAuth(!expandAuth); + }} + > + Auth + + +
+ )} + {showParams && ( +
+ { + e.preventDefault(); + setExpandParams(!expandParams); + }} + > + Parameters + + +
+ )} + {showRequestBody && ( +
+ { + e.preventDefault(); + setExpandBody(!expandBody); + }} + > + Body + {requestBodyRequired && ( + +  required + + )} + + <> + + + +
+ )} + {showAcceptOptions && ( +
+ { + e.preventDefault(); + setExpandAccept(!expandAccept); + }} + > + Accept + + +
+ )} + {showRequestButton && item.method !== "event" && ( + + )} +
+
+
+ ); +} + +export default Request; diff --git a/src/theme/ApiExplorer/Request/makeRequest.ts b/src/theme/ApiExplorer/Request/makeRequest.ts new file mode 100644 index 00000000..d833daa0 --- /dev/null +++ b/src/theme/ApiExplorer/Request/makeRequest.ts @@ -0,0 +1,247 @@ +import { Body } from "@theme/ApiExplorer/Body/slice"; +import sdk from "postman-collection"; + +function fetchWithtimeout( + url: string, + options: RequestInit, + timeout = 5000 +): any { + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Request timed out")), timeout) + ), + ]); +} + +async function loadImage(content: Blob): Promise { + return new Promise((accept, reject) => { + const reader = new FileReader(); + + reader.onabort = () => { + console.log("file reading was aborted"); + reject(); + }; + + reader.onerror = () => { + console.log("file reading has failed"); + reject(); + }; + + reader.onload = () => { + // Do whatever you want with the file contents + const binaryStr = reader.result; + accept(binaryStr); + }; + reader.readAsArrayBuffer(content); + }); +} + +async function makeRequest( + request: sdk.Request, + proxy: string | undefined, + _body: Body +) { + const headers = request.toJSON().header; + + let myHeaders = new Headers(); + if (headers) { + headers.forEach((header: any) => { + if (header.key && header.value) { + myHeaders.append(header.key, header.value); + } + }); + } + + // The following code handles multiple files in the same formdata param. + // It removes the form data params where the src property is an array of filepath strings + // Splits that array into different form data params with src set as a single filepath string + // TODO: + // if (request.body && request.body.mode === 'formdata') { + // let formdata = request.body.formdata, + // formdataArray = []; + // formdata.members.forEach((param) => { + // let key = param.key, + // type = param.type, + // disabled = param.disabled, + // contentType = param.contentType; + // // check if type is file or text + // if (type === 'file') { + // // if src is not of type string we check for array(multiple files) + // if (typeof param.src !== 'string') { + // // if src is an array(not empty), iterate over it and add files as separate form fields + // if (Array.isArray(param.src) && param.src.length) { + // param.src.forEach((filePath) => { + // addFormParam( + // formdataArray, + // key, + // param.type, + // filePath, + // disabled, + // contentType + // ); + // }); + // } + // // if src is not an array or string, or is an empty array, add a placeholder for file path(no files case) + // else { + // addFormParam( + // formdataArray, + // key, + // param.type, + // '/path/to/file', + // disabled, + // contentType + // ); + // } + // } + // // if src is string, directly add the param with src as filepath + // else { + // addFormParam( + // formdataArray, + // key, + // param.type, + // param.src, + // disabled, + // contentType + // ); + // } + // } + // // if type is text, directly add it to formdata array + // else { + // addFormParam( + // formdataArray, + // key, + // param.type, + // param.value, + // disabled, + // contentType + // ); + // } + // }); + // request.body.update({ + // mode: 'formdata', + // formdata: formdataArray, + // }); + // } + + const body = request.body?.toJSON(); + + let myBody: RequestInit["body"] = undefined; + if (body !== undefined && Object.keys(body).length > 0) { + switch (body.mode) { + case "urlencoded": { + myBody = new URLSearchParams(); + if (Array.isArray(body.urlencoded)) { + for (const data of body.urlencoded) { + if (data.key && data.value) { + myBody.append(data.key, data.value); + } + } + } + break; + } + case "raw": { + myBody = (body.raw ?? "").toString(); + break; + } + case "formdata": { + // The Content-Type header will be set automatically based on the type of body. + myHeaders.delete("Content-Type"); + + myBody = new FormData(); + if (Array.isArray(request.body.formdata.members)) { + for (const data of request.body.formdata.members) { + if (data.key && data.value.content) { + myBody.append(data.key, data.value.content); + } + // handle generic key-value payload + if (data.key && typeof data.value === "string") { + myBody.append(data.key, data.value); + } + } + } + break; + } + case "file": { + if (_body.type === "raw" && _body.content?.type === "file") { + myBody = await loadImage(_body.content.value.content); + } + break; + } + default: + break; + } + } + + const requestOptions: RequestInit = { + method: request.method, + headers: myHeaders, + body: myBody, + }; + + let finalUrl = request.url.toString(); + if (proxy) { + // Ensure the proxy ends with a slash. + let normalizedProxy = proxy.replace(/\/$/, "") + "/"; + finalUrl = normalizedProxy + request.url.toString(); + } + + return fetchWithtimeout(finalUrl, requestOptions).then((response: any) => { + const contentType = response.headers.get("content-type"); + let fileExtension = ""; + + if (contentType) { + if (contentType.includes("application/pdf")) { + fileExtension = ".pdf"; + } else if (contentType.includes("image/jpeg")) { + fileExtension = ".jpg"; + } else if (contentType.includes("image/png")) { + fileExtension = ".png"; + } else if (contentType.includes("image/gif")) { + fileExtension = ".gif"; + } else if (contentType.includes("image/webp")) { + fileExtension = ".webp"; + } else if (contentType.includes("video/mpeg")) { + fileExtension = ".mpeg"; + } else if (contentType.includes("video/mp4")) { + fileExtension = ".mp4"; + } else if (contentType.includes("audio/mpeg")) { + fileExtension = ".mp3"; + } else if (contentType.includes("audio/ogg")) { + fileExtension = ".ogg"; + } else if (contentType.includes("application/octet-stream")) { + fileExtension = ".bin"; + } else if (contentType.includes("application/zip")) { + fileExtension = ".zip"; + } + + if (fileExtension) { + return response.blob().then((blob: any) => { + const url = window.URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + // Now the file name includes the extension + link.setAttribute("download", `file${fileExtension}`); + + // These two lines are necessary to make the link click in Firefox + link.style.display = "none"; + document.body.appendChild(link); + + link.click(); + + // After link is clicked, it's safe to remove it. + setTimeout(() => document.body.removeChild(link), 0); + + return response; + }); + } else { + return response; + } + } + + return response; + }); +} + +export default makeRequest; From 7d31c6902b287c00f343a1d6fd7807d865a9b442 Mon Sep 17 00:00:00 2001 From: Matt Nemitz Date: Mon, 28 Jul 2025 09:59:39 +0100 Subject: [PATCH 2/8] wip --- .../container/cpu-speech-to-text.mdx | 3 +- docs/index.mdx | 6 + docusaurus.config.ts | 1 + package-lock.json | 230 ++++++++++++++++++ package.json | 7 +- 5 files changed, 245 insertions(+), 2 deletions(-) diff --git a/docs/deployments/container/cpu-speech-to-text.mdx b/docs/deployments/container/cpu-speech-to-text.mdx index 329f581d..3082bcfa 100644 --- a/docs/deployments/container/cpu-speech-to-text.mdx +++ b/docs/deployments/container/cpu-speech-to-text.mdx @@ -8,6 +8,7 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import { smVariables } from "/sm-variables"; import CodeBlock from "@theme/CodeBlock"; +import Code from "@theme/Code"; # CPU Speech to text container @@ -175,7 +176,7 @@ Once the above image is built, and a Container instantiated from it, a script ca Speechmatics has been unmodified. If you experience issues, Speechmatics support will require you to replicate the issues with the unmodified Docker image e.g. - `batch-asr-transcriber-en:{smVariables.latestContainerVersion}` + {`batch-asr-transcriber-en:{smVariables.latestContainerVersion}`} ::: ### Parallel processing guide diff --git a/docs/index.mdx b/docs/index.mdx index 1f83a6a6..7048a7c2 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -25,6 +25,12 @@ import { Box, Flex, Card, Grid, Inset } from "@radix-ui/themes"; ## What is Speechmatics? +```json live +{ + "message": "Hello world" +} +``` + Speechmatics is a developer platform for integrating powerful speech-to-text and conversational voice AI solutions into your applications and workflows. We handle the underlying voice technology infrastructure, so you can focus on building seamless voice experiences. With Speechmatics, you can: diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 20211198..86b3e603 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -168,6 +168,7 @@ const config: Config = { }, }, ], + "@docusaurus/theme-live-codeblock", [ "@signalwire/docusaurus-plugin-llms-txt", { diff --git a/package-lock.json b/package-lock.json index 2cb8e9dc..6b9bb965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@docusaurus/core": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", + "@docusaurus/theme-live-codeblock": "^3.8.1", "@docusaurus/theme-mermaid": "^3.8.1", "@mdx-js/react": "^3.0.0", "@radix-ui/themes": "^3.2.1", @@ -4589,6 +4590,30 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@docusaurus/theme-live-codeblock": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-live-codeblock/-/theme-live-codeblock-3.8.1.tgz", + "integrity": "sha512-TuCdnbJdTCAR4xv/dEU9m299/+hr+DrxQnQyK1mAmxnvWM/KrfaWdKMfjJ9h4hHa54ctPGm6ykdTvZic0GWdIw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-translations": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", + "@philpl/buble": "^0.19.7", + "clsx": "^2.0.0", + "fs-extra": "^11.1.1", + "react-live": "^4.1.6", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@docusaurus/theme-mermaid": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.1.tgz", @@ -5878,6 +5903,186 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@philpl/buble": { + "version": "0.19.7", + "resolved": "https://registry.npmjs.org/@philpl/buble/-/buble-0.19.7.tgz", + "integrity": "sha512-wKTA2DxAGEW+QffRQvOhRQ0VBiYU2h2p8Yc1oBNlqSKws48/8faxqKNIuub0q4iuyTuLwtB8EkwiKwhlfV1PBA==", + "license": "MIT", + "dependencies": { + "acorn": "^6.1.1", + "acorn-class-fields": "^0.2.1", + "acorn-dynamic-import": "^4.0.0", + "acorn-jsx": "^5.0.1", + "chalk": "^2.4.2", + "magic-string": "^0.25.2", + "minimist": "^1.2.0", + "os-homedir": "^1.0.1", + "regexpu-core": "^4.5.4" + }, + "bin": { + "buble": "bin/buble" + } + }, + "node_modules/@philpl/buble/node_modules/acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@philpl/buble/node_modules/acorn-class-fields": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/acorn-class-fields/-/acorn-class-fields-0.2.1.tgz", + "integrity": "sha512-US/kqTe0H8M4LN9izoL+eykVAitE68YMuYZ3sHn3i1fjniqR7oQ3SPvuMK/VT1kjOQHrx5Q88b90TtOKgAv2hQ==", + "license": "MIT", + "engines": { + "node": ">=4.8.2" + }, + "peerDependencies": { + "acorn": "^6.0.0" + } + }, + "node_modules/@philpl/buble/node_modules/acorn-dynamic-import": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz", + "integrity": "sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw==", + "deprecated": "This is probably built in to whatever tool you're using. If you still need it... idk", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0" + } + }, + "node_modules/@philpl/buble/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@philpl/buble/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@philpl/buble/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@philpl/buble/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@philpl/buble/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@philpl/buble/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@philpl/buble/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/@philpl/buble/node_modules/regenerate-unicode-properties": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz", + "integrity": "sha512-3E12UeNSPfjrgwjkR81m5J7Aw/T55Tu7nUyZVQYCKEOs+2dkxEY+DpPtZzO4YruuiPb7NkYLVcyJC4+zCbk5pA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@philpl/buble/node_modules/regexpu-core": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.8.0.tgz", + "integrity": "sha512-1F6bYsoYiz6is+oz70NWur2Vlh9KWtswuRuzJOfeYUrfPX2o8n74AnUVaOGDbUqVGO9fNHu48/pjJO4sNVwsOg==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^9.0.0", + "regjsgen": "^0.5.2", + "regjsparser": "^0.7.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@philpl/buble/node_modules/regjsgen": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.2.tgz", + "integrity": "sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==", + "license": "MIT" + }, + "node_modules/@philpl/buble/node_modules/regjsparser": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.7.0.tgz", + "integrity": "sha512-A4pcaORqmNMDVwUjWoTzuhwMGpP+NykpfqAsEgI1FSH/EzC7lrN5TMd+kN8YCovX+jMpu8eaqXgXPCa0g8FQNQ==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/@philpl/buble/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -17903,6 +18108,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -21480,6 +21694,15 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -26802,6 +27025,13 @@ "node": ">=0.10.0" } }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "license": "MIT" + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", diff --git a/package.json b/package.json index e5e02821..6c4daf10 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@docusaurus/core": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", + "@docusaurus/theme-live-codeblock": "^3.8.1", "@docusaurus/theme-mermaid": "^3.8.1", "@mdx-js/react": "^3.0.0", "@radix-ui/themes": "^3.2.1", @@ -60,7 +61,11 @@ "postman-code-generators": "1.10.1" }, "browserslist": { - "production": [">0.5%", "not dead", "not op_mini all"], + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], "development": [ "last 3 chrome version", "last 3 firefox version", From 16a027073669ab08f59d7f91360a47e08119e7b8 Mon Sep 17 00:00:00 2001 From: Matt Nemitz Date: Mon, 28 Jul 2025 14:46:39 +0100 Subject: [PATCH 3/8] Add bearer prefix to API key --- .../container/cpu-speech-to-text.mdx | 4 - docs/index.mdx | 6 - package.json | 6 +- spec/batch.yaml | 9 ++ src/css/infima-overrides.css | 1 + .../FormTextInput/_FormTextInput.scss | 34 ----- src/theme/ApiExplorer/FormTextInput/index.tsx | 31 +++-- .../ParamFormItems/ParamBooleanFormItem.tsx | 9 +- .../ParamFormItems/ParamTextFormItem.tsx | 15 +-- src/theme/ApiExplorer/Request/index.tsx | 69 ++++++---- src/theme/ApiExplorer/Request/makeRequest.ts | 119 +++++++++--------- 11 files changed, 150 insertions(+), 153 deletions(-) delete mode 100644 src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss diff --git a/docs/deployments/container/cpu-speech-to-text.mdx b/docs/deployments/container/cpu-speech-to-text.mdx index 54b3244c..8120a84b 100644 --- a/docs/deployments/container/cpu-speech-to-text.mdx +++ b/docs/deployments/container/cpu-speech-to-text.mdx @@ -8,11 +8,7 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import { smVariables } from "/sm-variables"; import CodeBlock from "@theme/CodeBlock"; -<<<<<<< HEAD -import Code from "@theme/Code"; -======= import Code from "@theme/CodeInline"; ->>>>>>> main # CPU Speech to text container diff --git a/docs/index.mdx b/docs/index.mdx index 7048a7c2..1f83a6a6 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -25,12 +25,6 @@ import { Box, Flex, Card, Grid, Inset } from "@radix-ui/themes"; ## What is Speechmatics? -```json live -{ - "message": "Hello world" -} -``` - Speechmatics is a developer platform for integrating powerful speech-to-text and conversational voice AI solutions into your applications and workflows. We handle the underlying voice technology infrastructure, so you can focus on building seamless voice experiences. With Speechmatics, you can: diff --git a/package.json b/package.json index 6c4daf10..507cf077 100644 --- a/package.json +++ b/package.json @@ -61,11 +61,7 @@ "postman-code-generators": "1.10.1" }, "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], + "production": [">0.5%", "not dead", "not op_mini all"], "development": [ "last 3 chrome version", "last 3 firefox version", diff --git a/spec/batch.yaml b/spec/batch.yaml index 229a53a7..adc1dae3 100644 --- a/spec/batch.yaml +++ b/spec/batch.yaml @@ -39,14 +39,23 @@ paths: - $ref: "#/parameters/EARTag" post: summary: Create a new job + description: | + Create and configure a new job. + + :::tip + The quickest way to create a transcription job is to use the [Speechmatics Portal](https://portal.speechmatics.com/jobs/create/batch). + ::: consumes: - multipart/form-data parameters: - name: config in: formData type: string + x-docusaurus-example-element: textarea + x-docusaurus-example-value: "{}" description: >- JSON containing a [`JobConfig`](/speech-to-text/batch/input#jobconfig-schema) model indicating the type and parameters for the recognition job. + example: "{}" required: true - name: data_file in: formData diff --git a/src/css/infima-overrides.css b/src/css/infima-overrides.css index abdf4e8e..e65b5ecf 100644 --- a/src/css/infima-overrides.css +++ b/src/css/infima-overrides.css @@ -129,6 +129,7 @@ html[data-theme="dark"] { --ifm-color-primary-lightest: var(--accent-7); --color-background: var(--gray-3); + --segmented-control-indicator-background-color: var(--color-panel); --ifm-background-color: var(--gray-3); --base-card-surface-hover-box-shadow: 0 0 0 1px color-mix(in oklab, var(--gray-a8), var(--gray-8) 25%); diff --git a/src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss b/src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss deleted file mode 100644 index 8c02e92a..00000000 --- a/src/theme/ApiExplorer/FormTextInput/_FormTextInput.scss +++ /dev/null @@ -1,34 +0,0 @@ -.openapi-explorer__form-item-input { - margin-top: calc(var(--ifm-pre-padding) / 2); - background-color: var(--openapi-input-background); - border: 1px solid transparent; - outline: none; - width: 100%; - color: var(--ifm-pre-color); - padding: var(--openapi-explorer-padding-input); - border-radius: 4px; - - &:hover { - border: 1px solid var(--ifm-toc-border-color); - } - - &:focus { - border: 1px solid var(--ifm-color-primary); - box-shadow: none; - } - - &.error { - border: 1px solid var(--openapi-required); - } -} - -.openapi-explorer__input-error { - font-size: var(--openapi-explorer-font-size-input); - color: var(--openapi-required); - padding-top: var(--openapi-explorer-padding-input); - - &::before { - display: inline; - content: "⚠ "; - } -} diff --git a/src/theme/ApiExplorer/FormTextInput/index.tsx b/src/theme/ApiExplorer/FormTextInput/index.tsx index fa6417af..3e3e9901 100644 --- a/src/theme/ApiExplorer/FormTextInput/index.tsx +++ b/src/theme/ApiExplorer/FormTextInput/index.tsx @@ -1,10 +1,9 @@ import type React from "react"; -import { forwardRef } from "react"; +import { forwardRef, useCallback } from "react"; import { ErrorMessage } from "@hookform/error-message"; -import clsx from "clsx"; -import { useFormContext } from "react-hook-form"; -import { TextField, Text } from "@radix-ui/themes"; +import { Text, TextField } from "@radix-ui/themes"; +import { useController, useFormContext } from "react-hook-form"; export interface Props { isRequired?: boolean; @@ -23,30 +22,44 @@ const FormTextInput = forwardRef( value, placeholder, password, - onChange, paramName, prefix, ...props }: Props, ref, ) => { + console.log(props); + placeholder = placeholder?.split("\n")[0]; const { - register, formState: { errors }, } = useFormContext(); const showErrorMessage = errors?.[paramName]?.message; + const controller = useController({ + name: paramName, + rules: { + required: isRequired ? "This field is required" : false, + }, + }); + + const onChange = useCallback( + (e: React.ChangeEvent) => { + controller.field.onChange(e); + props.onChange?.(e); + }, + [controller, props.onChange], + ); + return ( <> {prefix} diff --git a/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx index 96cabbfa..802408d8 100644 --- a/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx +++ b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamBooleanFormItem.tsx @@ -1,11 +1,11 @@ import React from "react"; import { ErrorMessage } from "@hookform/error-message"; +import { SegmentedControl } from "@radix-ui/themes"; import FormSelect from "@theme/ApiExplorer/FormSelect"; -import { Param, setParam } from "@theme/ApiExplorer/ParamOptions/slice"; +import { type Param, setParam } from "@theme/ApiExplorer/ParamOptions/slice"; import { useTypedDispatch } from "@theme/ApiItem/hooks"; import { Controller, useFormContext } from "react-hook-form"; -import { SegmentedControl } from "@radix-ui/themes"; export interface ParamProps { param: Param; @@ -28,7 +28,10 @@ export default function ParamBooleanFormItem({ param }: ParamProps) { rules={{ required: param.required ? "This field is required" : false }} name="paramBoolean" render={({ field: { onChange, name } }) => ( - + + + (empty) + true false diff --git a/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx index 02334960..7e7dd847 100644 --- a/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx +++ b/src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamTextFormItem.tsx @@ -1,9 +1,9 @@ -import React from "react"; +import type React from "react"; +import { Box, Code, Flex } from "@radix-ui/themes"; import FormTextInput from "@theme/ApiExplorer/FormTextInput"; -import { Param, setParam } from "@theme/ApiExplorer/ParamOptions/slice"; +import { type Param, setParam } from "@theme/ApiExplorer/ParamOptions/slice"; import { useTypedDispatch } from "@theme/ApiItem/hooks"; -import { Box, Code, Flex } from "@radix-ui/themes"; export interface ParamProps { param: Param; @@ -21,8 +21,9 @@ export default function ParamTextFormItem(props: ParamProps) { paramName={param.name} placeholder={param.description || param.name} prefix={prefix} - onChange={(e: React.ChangeEvent) => - dispatch( + onChange={(e: React.ChangeEvent) => { + console.log("DISPATCH", e); + return dispatch( setParam({ ...param, value: @@ -30,8 +31,8 @@ export default function ParamTextFormItem(props: ParamProps) { ? `${prefix}${e.target.value.replace(/\s/g, "%20")}` : `${prefix}${e.target.value}`, }), - ) - } + ); + }} /> ); diff --git a/src/theme/ApiExplorer/Request/index.tsx b/src/theme/ApiExplorer/Request/index.tsx index 26e4e901..8bd4ac07 100644 --- a/src/theme/ApiExplorer/Request/index.tsx +++ b/src/theme/ApiExplorer/Request/index.tsx @@ -5,20 +5,20 @@ import { useDoc } from "@docusaurus/plugin-content-docs/client"; import Accept from "@theme/ApiExplorer/Accept"; import Authorization from "@theme/ApiExplorer/Authorization"; import Body from "@theme/ApiExplorer/Body"; -import buildPostmanRequest from "@theme/ApiExplorer/buildPostmanRequest"; import ContentType from "@theme/ApiExplorer/ContentType"; import ParamOptions from "@theme/ApiExplorer/ParamOptions"; import { - setResponse, - setCode, clearCode, - setHeaders, clearHeaders, + setCode, + setHeaders, + setResponse, } from "@theme/ApiExplorer/Response/slice"; import Server from "@theme/ApiExplorer/Server"; +import buildPostmanRequest from "@theme/ApiExplorer/buildPostmanRequest"; import { useTypedDispatch, useTypedSelector } from "@theme/ApiItem/hooks"; -import { ParameterObject } from "docusaurus-plugin-openapi-docs/src/openapi/types"; -import { ApiItem } from "docusaurus-plugin-openapi-docs/src/types"; +import type { ParameterObject } from "docusaurus-plugin-openapi-docs/src/openapi/types"; +import type { ApiItem } from "docusaurus-plugin-openapi-docs/src/types"; import sdk from "postman-collection"; import { FormProvider, useForm } from "react-hook-form"; @@ -29,18 +29,30 @@ function Request({ item }: { item: ApiItem }) { const metadata = useDoc(); const { proxy, hide_send_button: hideSendButton } = metadata.frontMatter; - const pathParams = useTypedSelector((state: any) => state.params.path); - const queryParams = useTypedSelector((state: any) => state.params.query); - const cookieParams = useTypedSelector((state: any) => state.params.cookie); - const contentType = useTypedSelector((state: any) => state.contentType.value); - const headerParams = useTypedSelector((state: any) => state.params.header); - const body = useTypedSelector((state: any) => state.body); - const accept = useTypedSelector((state: any) => state.accept.value); - const acceptOptions = useTypedDispatch((state: any) => state.accept.options); - const authSelected = useTypedSelector((state: any) => state.auth.selected); - const server = useTypedSelector((state: any) => state.server.value); - const serverOptions = useTypedSelector((state: any) => state.server.options); - const auth = useTypedSelector((state: any) => state.auth); + const pathParams = useTypedSelector((state: unknown) => state.params.path); + const queryParams = useTypedSelector((state: unknown) => state.params.query); + const cookieParams = useTypedSelector( + (state: unknown) => state.params.cookie, + ); + const contentType = useTypedSelector( + (state: unknown) => state.contentType.value, + ); + const headerParams = useTypedSelector( + (state: unknown) => state.params.header, + ); + const body = useTypedSelector((state: unknown) => state.body); + const accept = useTypedSelector((state: unknown) => state.accept.value); + const acceptOptions = useTypedDispatch( + (state: unknown) => state.accept.options, + ); + const authSelected = useTypedSelector( + (state: unknown) => state.auth.selected, + ); + const server = useTypedSelector((state: unknown) => state.server.value); + const serverOptions = useTypedSelector( + (state: unknown) => state.server.options, + ); + const auth = useTypedSelector((state: unknown) => state.auth); const dispatch = useTypedDispatch(); const [expandAccept, setExpandAccept] = useState(true); @@ -78,13 +90,11 @@ function Request({ item }: { item: ApiItem }) { cookie: [] as ParameterObject[], }; - item.parameters?.forEach( - (param: { in: "path" | "query" | "header" | "cookie" }) => { - const paramType = param.in; - const paramsArray: ParameterObject[] = paramsObject[paramType]; - paramsArray.push(param as ParameterObject); - } - ); + for (const param of item.parameters) { + const paramType = param.in; + const paramsArray: ParameterObject[] = paramsObject[paramType]; + paramsArray.push(param as ParameterObject); + } const methods = useForm({ shouldFocusError: false }); @@ -119,7 +129,7 @@ function Request({ item }: { item: ApiItem }) { } else { await handleResponse(res); } - } catch (e: any) { + } catch (e) { console.log(e); dispatch(setResponse("Connection failed")); dispatch(clearCode()); @@ -173,6 +183,7 @@ function Request({ item }: { item: ApiItem }) {
Request {allDetailsExpanded ? ( + // biome-ignore lint/a11y/useKeyWithClickEvents: ) : ( + // biome-ignore lint/a11y/useKeyWithClickEvents: + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} { @@ -211,6 +224,7 @@ function Request({ item }: { item: ApiItem }) { open={expandAuth} className="openapi-explorer__details-container" > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} { @@ -230,6 +244,7 @@ function Request({ item }: { item: ApiItem }) { } className="openapi-explorer__details-container" > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} { @@ -247,6 +262,7 @@ function Request({ item }: { item: ApiItem }) { open={expandBody} className="openapi-explorer__details-container" > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} { @@ -276,6 +292,7 @@ function Request({ item }: { item: ApiItem }) { open={expandAccept} className="openapi-explorer__details-container" > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} { diff --git a/src/theme/ApiExplorer/Request/makeRequest.ts b/src/theme/ApiExplorer/Request/makeRequest.ts index d833daa0..bfbdc788 100644 --- a/src/theme/ApiExplorer/Request/makeRequest.ts +++ b/src/theme/ApiExplorer/Request/makeRequest.ts @@ -1,15 +1,15 @@ -import { Body } from "@theme/ApiExplorer/Body/slice"; -import sdk from "postman-collection"; +import type { Body } from "@theme/ApiExplorer/Body/slice"; +import type sdk from "postman-collection"; function fetchWithtimeout( url: string, options: RequestInit, - timeout = 5000 -): any { + timeout = 5000, +): Promise { return Promise.race([ fetch(url, options), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Request timed out")), timeout) + new Promise((_, reject) => + setTimeout(() => reject(new Error("Request timed out")), timeout), ), ]); } @@ -40,17 +40,18 @@ async function loadImage(content: Blob): Promise { async function makeRequest( request: sdk.Request, proxy: string | undefined, - _body: Body + _body: Body, ) { const headers = request.toJSON().header; + console.log("makeRequest", request); - let myHeaders = new Headers(); + const myHeaders = new Headers(); if (headers) { - headers.forEach((header: any) => { + for (const header of headers) { if (header.key && header.value) { myHeaders.append(header.key, header.value); } - }); + } } // The following code handles multiple files in the same formdata param. @@ -182,66 +183,66 @@ async function makeRequest( let finalUrl = request.url.toString(); if (proxy) { // Ensure the proxy ends with a slash. - let normalizedProxy = proxy.replace(/\/$/, "") + "/"; + const normalizedProxy = `${proxy.replace(/\/$/, "")}/`; finalUrl = normalizedProxy + request.url.toString(); } - return fetchWithtimeout(finalUrl, requestOptions).then((response: any) => { - const contentType = response.headers.get("content-type"); - let fileExtension = ""; - - if (contentType) { - if (contentType.includes("application/pdf")) { - fileExtension = ".pdf"; - } else if (contentType.includes("image/jpeg")) { - fileExtension = ".jpg"; - } else if (contentType.includes("image/png")) { - fileExtension = ".png"; - } else if (contentType.includes("image/gif")) { - fileExtension = ".gif"; - } else if (contentType.includes("image/webp")) { - fileExtension = ".webp"; - } else if (contentType.includes("video/mpeg")) { - fileExtension = ".mpeg"; - } else if (contentType.includes("video/mp4")) { - fileExtension = ".mp4"; - } else if (contentType.includes("audio/mpeg")) { - fileExtension = ".mp3"; - } else if (contentType.includes("audio/ogg")) { - fileExtension = ".ogg"; - } else if (contentType.includes("application/octet-stream")) { - fileExtension = ".bin"; - } else if (contentType.includes("application/zip")) { - fileExtension = ".zip"; - } + return fetchWithtimeout(finalUrl, requestOptions).then( + (response: Response) => { + const contentType = response.headers.get("content-type"); + let fileExtension = ""; + + if (contentType) { + if (contentType.includes("application/pdf")) { + fileExtension = ".pdf"; + } else if (contentType.includes("image/jpeg")) { + fileExtension = ".jpg"; + } else if (contentType.includes("image/png")) { + fileExtension = ".png"; + } else if (contentType.includes("image/gif")) { + fileExtension = ".gif"; + } else if (contentType.includes("image/webp")) { + fileExtension = ".webp"; + } else if (contentType.includes("video/mpeg")) { + fileExtension = ".mpeg"; + } else if (contentType.includes("video/mp4")) { + fileExtension = ".mp4"; + } else if (contentType.includes("audio/mpeg")) { + fileExtension = ".mp3"; + } else if (contentType.includes("audio/ogg")) { + fileExtension = ".ogg"; + } else if (contentType.includes("application/octet-stream")) { + fileExtension = ".bin"; + } else if (contentType.includes("application/zip")) { + fileExtension = ".zip"; + } - if (fileExtension) { - return response.blob().then((blob: any) => { - const url = window.URL.createObjectURL(blob); + if (fileExtension) { + return response.blob().then((blob) => { + const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - // Now the file name includes the extension - link.setAttribute("download", `file${fileExtension}`); + const link = document.createElement("a"); + link.href = url; + // Now the file name includes the extension + link.setAttribute("download", `file${fileExtension}`); - // These two lines are necessary to make the link click in Firefox - link.style.display = "none"; - document.body.appendChild(link); + // These two lines are necessary to make the link click in Firefox + link.style.display = "none"; + document.body.appendChild(link); - link.click(); + link.click(); - // After link is clicked, it's safe to remove it. - setTimeout(() => document.body.removeChild(link), 0); + // After link is clicked, it's safe to remove it. + setTimeout(() => document.body.removeChild(link), 0); - return response; - }); - } else { - return response; + return response; + }); + } } - } - return response; - }); + return response; + }, + ); } export default makeRequest; From 26165d828e9035066236df94c81ae52631b6b144 Mon Sep 17 00:00:00 2001 From: Matt Nemitz Date: Tue, 29 Jul 2025 15:17:37 +0100 Subject: [PATCH 4/8] wip --- .../ApiExplorer/FormSelect/_FormSelect.scss | 43 +++++++ src/theme/ApiExplorer/FormSelect/index.tsx | 31 +++++ src/theme/ApiExplorer/FormTextInput/index.tsx | 109 ++++++++++-------- .../ParamFormItems/ParamSelectFormItem.tsx | 58 ++++++++++ 4 files changed, 190 insertions(+), 51 deletions(-) create mode 100644 src/theme/ApiExplorer/FormSelect/_FormSelect.scss create mode 100644 src/theme/ApiExplorer/FormSelect/index.tsx create mode 100644 src/theme/ApiExplorer/ParamOptions/ParamFormItems/ParamSelectFormItem.tsx diff --git a/src/theme/ApiExplorer/FormSelect/_FormSelect.scss b/src/theme/ApiExplorer/FormSelect/_FormSelect.scss new file mode 100644 index 00000000..86872613 --- /dev/null +++ b/src/theme/ApiExplorer/FormSelect/_FormSelect.scss @@ -0,0 +1,43 @@ +html[data-theme="dark"] .openapi-explorer__select-input { + margin-top: calc(var(--ifm-pre-padding) / 2); + background-color: var(--openapi-input-background); + border: none; + outline: none; + width: 100%; + color: var(--ifm-pre-color); + + border-radius: 4px; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + + background-image: url('data:image/svg+xml;charset=US-ASCII,'); + background-repeat: no-repeat; + background-position: right var(--ifm-pre-padding) top 50%; + background-size: auto auto; +} + +.openapi-explorer__select-input { + width: 100%; + margin-top: calc(var(--ifm-pre-padding) / 2); + padding: var(--openapi-explorer-padding-input); + border: none; + outline: none; + border-radius: 4px; + background-color: var(--openapi-input-background); + font-size: var(--openapi-explorer-font-size-input); + font-family: var(--ifm-font-family-monospace); + color: var(--ifm-pre-color); + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + + background-image: url('data:image/svg+xml;charset=US-ASCII,'); + background-repeat: no-repeat; + background-position: right var(--ifm-pre-padding) top 50%; + background-size: auto auto; + + &:focus { + box-shadow: inset 0px 0px 0px 2px var(--openapi-input-border); + } +} diff --git a/src/theme/ApiExplorer/FormSelect/index.tsx b/src/theme/ApiExplorer/FormSelect/index.tsx new file mode 100644 index 00000000..2c6ab28b --- /dev/null +++ b/src/theme/ApiExplorer/FormSelect/index.tsx @@ -0,0 +1,31 @@ +import type React from "react"; + +export interface Props { + value?: string; + options?: string[]; + onChange?: React.ChangeEventHandler; +} + +function FormSelect({ value, options, onChange }: Props) { + if (!Array.isArray(options) || options.length === 0) { + return null; + } + + return ( + + ); +} + +export default FormSelect; diff --git a/src/theme/ApiExplorer/FormTextInput/index.tsx b/src/theme/ApiExplorer/FormTextInput/index.tsx index 3e3e9901..34ad02bd 100644 --- a/src/theme/ApiExplorer/FormTextInput/index.tsx +++ b/src/theme/ApiExplorer/FormTextInput/index.tsx @@ -1,8 +1,9 @@ import type React from "react"; -import { forwardRef, useCallback } from "react"; +import { forwardRef, useCallback, useEffect } from "react"; +import { useDoc } from "@docusaurus/plugin-content-docs/client"; import { ErrorMessage } from "@hookform/error-message"; -import { Text, TextField } from "@radix-ui/themes"; +import { Box, Text, TextArea, TextField } from "@radix-ui/themes"; import { useController, useFormContext } from "react-hook-form"; export interface Props { @@ -11,11 +12,11 @@ export interface Props { value?: string; placeholder?: string; password?: boolean; - onChange?: React.ChangeEventHandler; + onChange?: React.ChangeEventHandler; prefix?: string; } -const FormTextInput = forwardRef( +const FormTextInput = forwardRef( ( { isRequired, @@ -28,8 +29,6 @@ const FormTextInput = forwardRef( }: Props, ref, ) => { - console.log(props); - placeholder = placeholder?.split("\n")[0]; const { @@ -46,17 +45,56 @@ const FormTextInput = forwardRef( }); const onChange = useCallback( - (e: React.ChangeEvent) => { + (e: React.ChangeEvent) => { controller.field.onChange(e); props.onChange?.(e); }, [controller, props.onChange], ); + const docId = useDoc().metadata.id; + const isJobConfig = + docId === "api-ref/batch/create-a-new-job" && paramName === "config"; + + useEffect(() => { + if (isJobConfig) { + controller.field.onChange(ref?.current?.value); + } + }, [isJobConfig, controller]); + + if (isJobConfig) { + return ( + <> + +