diff --git a/.changeset/angry-dingos-poke.md b/.changeset/angry-dingos-poke.md index aa430a4f3..4e75503f9 100644 --- a/.changeset/angry-dingos-poke.md +++ b/.changeset/angry-dingos-poke.md @@ -2,4 +2,4 @@ 'fumadocs-openapi': minor --- -Support Server Action Proxy +Support Route Handler Proxy diff --git a/apps/docs/app/api/proxy/route.ts b/apps/docs/app/api/proxy/route.ts new file mode 100644 index 000000000..1823e625f --- /dev/null +++ b/apps/docs/app/api/proxy/route.ts @@ -0,0 +1,3 @@ +import { openapi } from '@/app/source'; + +export const { GET, HEAD, PUT, POST, PATCH, DELETE } = openapi.createProxy(); diff --git a/apps/docs/app/source.ts b/apps/docs/app/source.ts index 881ed64ca..082011dbb 100644 --- a/apps/docs/app/source.ts +++ b/apps/docs/app/source.ts @@ -27,6 +27,7 @@ export const blog = loader({ }); export const openapi = createOpenAPI({ + proxyUrl: '/api/proxy', shikiOptions: { themes: { dark: 'vesper', diff --git a/apps/docs/museum.yaml b/apps/docs/museum.yaml index 91c398600..c9f58a4c6 100644 --- a/apps/docs/museum.yaml +++ b/apps/docs/museum.yaml @@ -1,8 +1,9 @@ openapi: 3.1.0 info: title: Redocly Museum API - description: An imaginary, but delightful Museum API for interacting with museum services and information. Built with love by Redocly. - version: 1.0.0 + description: Imaginary, but delightful Museum API for interacting with museum services and information. Built with love by Redocly. + version: 1.2.1 + termsOfService: 'https://redocly.com/subscription-agreement/' contact: email: team@redocly.com url: 'https://redocly.com/docs/cli/' @@ -16,7 +17,7 @@ paths: /museum-hours: get: summary: Get museum hours - description: Get upcoming museum operating hours + description: Get upcoming museum operating hours. operationId: getMuseumHours tags: - Operations @@ -26,18 +27,18 @@ paths: - $ref: '#/components/parameters/PaginationLimit' responses: '200': - description: Success + description: Success. content: application/json: schema: - $ref: '#/components/schemas/GetMuseumHoursResponse' + $ref: '#/components/schemas/MuseumHours' examples: default_example: $ref: '#/components/examples/GetMuseumHoursResponseExample' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' /special-events: post: summary: Create special events @@ -50,52 +51,25 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateSpecialEventRequest' + $ref: '#/components/schemas/SpecialEvent' examples: default_example: $ref: '#/components/examples/CreateSpecialEventRequestExample' - callbacks: - createEvent: - '{$request.query.callbackUrl}?event={$request.query.event}': - post: - summary: Create Event Notification - requestBody: - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: - message: Some event - responses: - '200': - description: OK responses: - '200': - description: Success + '201': + description: Created. content: application/json: schema: - $ref: '#/components/schemas/SpecialEventResponse' + $ref: '#/components/schemas/SpecialEvent' examples: default_example: $ref: '#/components/examples/CreateSpecialEventResponseExample' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' get: - x-codeSamples: - - lang: js - label: JavaScript - - lang: go - label: Go - - lang: bash - label: cURL - - lang: python - label: Python summary: List special events description: Return a list of upcoming special events at the museum. operationId: listSpecialEvents @@ -108,18 +82,18 @@ paths: - $ref: '#/components/parameters/PaginationLimit' responses: '200': - description: Success + description: Success. content: application/json: schema: - $ref: '#/components/schemas/ListSpecialEventsResponse' + $ref: '#/components/schemas/SpecialEventCollection' examples: default_example: $ref: '#/components/examples/ListSpecialEventsResponseExample' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' /special-events/{eventId}: get: summary: Get special event @@ -131,21 +105,21 @@ paths: - $ref: '#/components/parameters/EventId' responses: '200': - description: Success + description: Success. content: application/json: schema: - $ref: '#/components/schemas/SpecialEventResponse' + $ref: '#/components/schemas/SpecialEvent' examples: default_example: $ref: '#/components/examples/GetSpecialEventResponseExample' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' patch: summary: Update special event - description: Update the details of a special event + description: Update the details of a special event. operationId: updateSpecialEvent tags: - Events @@ -156,24 +130,24 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UpdateSpecialEventRequest' + $ref: '#/components/schemas/SpecialEventFields' examples: default_example: $ref: '#/components/examples/UpdateSpecialEventRequestExample' responses: '200': - description: Success + description: Success. content: application/json: schema: - $ref: '#/components/schemas/SpecialEventResponse' + $ref: '#/components/schemas/SpecialEvent' examples: default_example: $ref: '#/components/examples/UpdateSpecialEventResponseExample' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' delete: summary: Delete special event description: Delete a special event from the collection. Allows museum to cancel planned events. @@ -184,13 +158,13 @@ paths: - $ref: '#/components/parameters/EventId' responses: '204': - description: Success - no content + description: Success - no content. '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '401': - description: Unauthorized + $ref: '#/components/responses/Unauthorized' '404': - description: Not found + $ref: '#/components/responses/NotFound' /tickets: post: summary: Buy museum tickets @@ -201,40 +175,30 @@ paths: requestBody: required: true content: - multipart/form-data: + application/json: schema: - allOf: - - $ref: '#/components/schemas/BuyMuseumTicketsRequest' - - type: object - properties: - test: - type: string - enum: - - general - - gold - - platinum - - vip + $ref: '#/components/schemas/BuyMuseumTickets' examples: general_entry: $ref: '#/components/examples/BuyGeneralTicketsRequestExample' event_entry: $ref: '#/components/examples/BuyEventTicketsRequestExample' responses: - '200': - description: Success + '201': + description: Created. content: application/json: schema: - $ref: '#/components/schemas/BuyMuseumTicketsResponse' + $ref: '#/components/schemas/MuseumTicketsConfirmation' examples: general_entry: $ref: '#/components/examples/BuyGeneralTicketsResponseExample' event_entry: $ref: '#/components/examples/BuyEventTicketsResponseExample' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' /tickets/{ticketId}/qr: get: summary: Get ticket QR code @@ -246,15 +210,35 @@ paths: - $ref: '#/components/parameters/TicketId' responses: '200': - description: Scannable event ticket in image format + description: Scannable event ticket in image format. content: image/png: schema: - $ref: '#/components/schemas/GetTicketCodeResponse' + $ref: '#/components/schemas/TicketCodeImage' '400': - description: Bad request + $ref: '#/components/responses/BadRequest' '404': - description: Not found + $ref: '#/components/responses/NotFound' +webhooks: + publishNewEvent: + post: + summary: New special event added + description: Publish details of a new or updated event. + operationId: publishNewEvent + tags: + - Events + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SpecialEvent' + examples: + default_example: + $ref: '#/components/examples/GetSpecialEventResponseExample' + responses: + '202': + description: Data accepted. + components: schemas: TicketType: @@ -267,39 +251,21 @@ components: Date: type: string format: date - example: 2023-10-29 + example: '2023-10-29' Email: description: Email address for ticket purchaser. type: string format: email example: museum-lover@example.com - Phone: - description: Phone number for the ticket purchaser (optional). - type: string - example: +1(234)-567-8910 - BuyMuseumTicketsRequest: - description: Request payload used for purchasing museum tickets. + BuyMuseumTickets: + description: Data to purchase a ticket. type: object - properties: - ticketType: - $ref: '#/components/schemas/TicketType' - eventId: - description: Unique identifier for a special event. Required if purchasing tickets for the museum's special events. - $ref: '#/components/schemas/EventId' - ticketDate: - description: Date that the ticket is valid for. - $ref: '#/components/schemas/Date' - email: - $ref: '#/components/schemas/Email' - phone: - $ref: '#/components/schemas/Phone' - image: - type: string - format: binary - required: - - ticketType - - ticketDate - - email + allOf: + - type: object + properties: + email: + $ref: '#/components/schemas/Email' + - $ref: '#/components/schemas/Ticket' TicketMessage: description: Confirmation message after a ticket purchase. type: string @@ -312,36 +278,43 @@ components: TicketConfirmation: description: Unique confirmation code used to verify ticket purchase. type: string - example: ticket-event-a98c8f-7eb12 - BuyMuseumTicketsResponse: - description: Details for a museum ticket after a successful purchase. + example: 'ticket-event-a98c8f-7eb12' + Ticket: + description: Ticket for museum entry, can be general admission or special event. type: object properties: - message: - $ref: '#/components/schemas/TicketMessage' - eventName: - $ref: '#/components/schemas/EventName' ticketId: $ref: '#/components/schemas/TicketId' - ticketType: - $ref: '#/components/schemas/TicketType' ticketDate: - description: Date the ticket is valid for. + description: Date when this ticket can be used for museum entry. $ref: '#/components/schemas/Date' - confirmationCode: - $ref: '#/components/schemas/TicketConfirmation' + ticketType: + $ref: '#/components/schemas/TicketType' + eventId: + description: Unique identifier for a special event. Required if purchasing tickets for the museum's special events. + $ref: '#/components/schemas/EventId' required: - - message - - ticketId - ticketType - ticketDate - - confirmationCode - GetTicketCodeResponse: - description: An image of a ticket with a QR code used for museum or event entry. + MuseumTicketsConfirmation: + description: Details for a museum ticket after a successful purchase. + allOf: + - $ref: '#/components/schemas/Ticket' + - type: object + properties: + message: + $ref: '#/components/schemas/TicketMessage' + confirmationCode: + $ref: '#/components/schemas/TicketConfirmation' + required: + - message + - confirmationCode + TicketCodeImage: + description: Image of a ticket with a QR code used for museum or event entry. type: string format: binary - GetMuseumHoursResponse: - description: List of museum operating hours for consecutive days. + MuseumHours: + description: List of museum operating hours for a date range. type: array items: $ref: '#/components/schemas/MuseumDailyHours' @@ -352,7 +325,7 @@ components: date: description: Date the operating hours apply to. $ref: '#/components/schemas/Date' - example: 02-02-2022 + example: '2024-12-31' timeOpen: type: string pattern: '^([01]\d|2[0-3]):?([0-5]\d)$' @@ -374,50 +347,28 @@ components: example: 3be6453c-03eb-4357-ae5a-984a0e574a54 EventName: type: string - description: Name of the special event + description: Name of the special event. example: Pirate Coding Workshop EventLocation: type: string - description: Location where the special event is held + description: Location where the special event is held. example: Computer Room EventDescription: type: string - description: Description of the special event + description: Description of the special event. example: Captain Blackbeard shares his love of the C...language. And possibly Arrrrr (R lang). EventDates: type: array items: $ref: '#/components/schemas/Date' - description: List of planned dates for the special event + description: List of planned dates for the special event. EventPrice: - description: Price of a ticket for the special event + description: Price of a ticket for the special event. type: number format: float example: 25 - CreateSpecialEventRequest: - description: Request payload for creating new special events at the museum. - properties: - name: - $ref: '#/components/schemas/EventName' - location: - $ref: '#/components/schemas/EventLocation' - eventDescription: - $ref: '#/components/schemas/EventDescription' - dates: - $ref: '#/components/schemas/EventDates' - price: - $ref: '#/components/schemas/EventPrice' - meta: - type: 'object' - additionalProperties: true - required: - - name - - location - - eventDescription - - dates - - price - UpdateSpecialEventRequest: - description: Request payload for updating an existing special event. Only included fields are updated in the event. + SpecialEventFields: + type: object properties: name: $ref: '#/components/schemas/EventName' @@ -429,13 +380,8 @@ components: $ref: '#/components/schemas/EventDates' price: $ref: '#/components/schemas/EventPrice' - ListSpecialEventsResponse: - description: A list of upcoming special events - type: array - items: - $ref: '#/components/schemas/SpecialEventResponse' - SpecialEventResponse: - description: Information about a special event. + SpecialEvent: + type: object properties: eventId: $ref: '#/components/schemas/EventId' @@ -450,12 +396,26 @@ components: price: $ref: '#/components/schemas/EventPrice' required: - - eventId - name - location - eventDescription - dates - price + SpecialEventCollection: + description: List of upcoming special events. + type: array + items: + $ref: '#/components/schemas/SpecialEvent' + Error: + type: object + properties: + type: + type: string + example: object + title: + type: string + example: Validation failed + securitySchemes: MuseumPlaceholderAuth: type: http @@ -465,14 +425,14 @@ components: summary: General entry ticket value: ticketType: general - ticketDate: 2023-09-07 + ticketDate: '2023-09-07' email: todd@example.com BuyEventTicketsRequestExample: summary: Special event ticket value: - ticketType: general + ticketType: event eventId: dad4bce8-f5cb-4078-a211-995864315e39 - ticketDate: 2023-09-05 + ticketDate: '2023-09-05' email: todd@example.com BuyGeneralTicketsResponseExample: summary: General entry ticket @@ -480,7 +440,7 @@ components: message: Museum general entry ticket purchased ticketId: 382c0820-0530-4f4b-99af-13811ad0f17a ticketType: general - ticketDate: 2023-09-07 + ticketDate: '2023-09-07' confirmationCode: ticket-general-e5e5c6-dce78 BuyEventTicketsResponseExample: summary: Special event ticket @@ -489,7 +449,7 @@ components: ticketId: b811f723-17b2-44f7-8952-24b03e43d8a9 eventName: Mermaid Treasure Identification and Analysis ticketType: event - ticketDate: 2023-09-05 + ticketDate: '2023-09-05' confirmationCode: ticket-event-9c55eg-8v82a CreateSpecialEventRequestExample: summary: Create special event @@ -498,8 +458,8 @@ components: location: Under the seaaa 🦀 🎶 🌊. eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel. dates: - - 2023-09-05 - - 2023-09-08 + - '2023-09-05' + - '2023-09-08' price: 0 CreateSpecialEventResponseExample: summary: Special event created @@ -509,8 +469,8 @@ components: location: Under the seaaa 🦀 🎶 🌊. eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel. dates: - - 2023-09-05 - - 2023-09-08 + - '2023-09-05' + - '2023-09-08' price: 30 GetSpecialEventResponseExample: summary: Get special event @@ -520,9 +480,9 @@ components: location: Temporal Tearoom eventDescription: Sip tea with important historical figures. dates: - - 2023-11-18 - - 2023-11-25 - - 2023-12-02 + - '2023-11-18' + - '2023-11-25' + - '2023-12-02' price: 60 ListSpecialEventsResponseExample: summary: List of special events @@ -532,85 +492,85 @@ components: location: Seattle... probably eventDescription: They're big, they're hairy, but they're also graceful. Come learn how the biggest feet can have the lightest touch. dates: - - 2023-12-15 - - 2023-12-22 + - '2023-12-15' + - '2023-12-22' price: 40 - eventId: 2f14374a-9c65-4ee5-94b7-fba66d893483 name: Solar Telescope Demonstration location: Far from the sun. eventDescription: Look at the sun without going blind! dates: - - 2023-09-07 - - 2023-09-14 + - '2023-09-07' + - '2023-09-14' price: 50 - eventId: 6aaa61ba-b2aa-4868-b803-603dbbf7bfdb name: Cook like a Caveman location: Fire Pit on East side eventDescription: Learn to cook on an open flame. dates: - - 2023-11-10 - - 2023-11-17 - - 2023-11-24 + - '2023-11-10' + - '2023-11-17' + - '2023-11-24' price: 5 - eventId: 602b75e1-5696-4ab8-8c7a-f9e13580f910 name: Underwater Basket Weaving location: Rec Center Pool next door. eventDescription: Learn to weave baskets underwater. dates: - - 2023-09-12 - - 2023-09-15 + - '2023-09-12' + - '2023-09-15' price: 15 - eventId: dad4bce8-f5cb-4078-a211-995864315e39 name: Mermaid Treasure Identification and Analysis location: Room Sea-12 eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits — kindly donated by Ariel. dates: - - 2023-09-05 - - 2023-09-08 + - '2023-09-05' + - '2023-09-08' price: 30 - eventId: 6744a0da-4121-49cd-8479-f8cc20526495 name: Time Traveler Tea Party location: Temporal Tearoom eventDescription: Sip tea with important historical figures. dates: - - 2023-11-18 - - 2023-11-25 - - 2023-12-02 + - '2023-11-18' + - '2023-11-25' + - '2023-12-02' price: 60 - eventId: 3be6453c-03eb-4357-ae5a-984a0e574a54 name: Pirate Coding Workshop location: Computer Room eventDescription: Captain Blackbeard shares his love of the C...language. And possibly Arrrrr (R lang). dates: - - 2023-10-29 - - 2023-10-30 - - 2023-10-31 + - '2023-10-29' + - '2023-10-30' + - '2023-10-31' price: 45 - eventId: 9d90d29a-2af5-4206-97d9-9ea9ceadcb78 name: Llama Street Art Through the Ages location: Auditorium eventDescription: Llama street art?! Alpaca my bags -- let's go! dates: - - 2023-10-29 - - 2023-10-30 - - 2023-10-31 + - '2023-10-29' + - '2023-10-30' + - '2023-10-31' price: 45 - eventId: a3c7b2c4-b5fb-4ef7-9322-00a919864957 name: The Great Parrot Debate location: Outdoor Amphitheatre eventDescription: See leading parrot minds discuss important geopolitical issues. dates: - - 2023-11-03 - - 2023-11-10 + - '2023-11-03' + - '2023-11-10' price: 35 - eventId: b92d46b7-4c5d-422b-87a5-287767e26f29 name: Eat a Bunch of Corn location: Cafeteria eventDescription: We accidentally bought too much corn. Please come eat it. dates: - - 2023-11-10 - - 2023-11-17 - - 2023-11-24 + - '2023-11-10' + - '2023-11-17' + - '2023-11-24' price: 5 UpdateSpecialEventRequestExample: summary: Update special event request @@ -625,8 +585,8 @@ components: location: On the beach. eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel. dates: - - 2023-09-05 - - 2023-09-08 + - '2023-09-05' + - '2023-09-08' price: 15 GetMuseumHoursResponseExample: summary: Get hours response @@ -665,7 +625,7 @@ components: PaginationPage: name: page in: query - description: The page number to retrieve. + description: Page number to retrieve. schema: type: integer default: 1 @@ -673,7 +633,7 @@ components: PaginationLimit: name: limit in: query - description: The number of days per page. + description: Number of days per page. schema: type: integer default: 10 @@ -682,7 +642,7 @@ components: EventId: name: eventId in: path - description: An identifier for a special event. + description: Identifier for a special event. required: true schema: type: string @@ -691,33 +651,52 @@ components: StartDate: name: startDate in: query - description: The starting date to retrieve future operating hours from. Defaults to today's date. + description: Starting date to retrieve future operating hours from. Defaults to today's date. schema: type: string format: date - example: 2023-02-23 + example: '2023-02-23' EndDate: name: endDate in: query - description: The end of a date range to retrieve special events for. Defaults to 7 days after `startDate`. + description: End of a date range to retrieve special events for. Defaults to 7 days after `startDate`. schema: type: string format: date - example: 2023-04-18 + example: '2023-04-18' TicketId: name: ticketId in: path - description: An identifier for a ticket to a museum event. Used to generate ticket image. + description: Identifier for a ticket to a museum event. Used to generate ticket image. required: true schema: type: string format: uuid example: a54a57ca-36f8-421b-a6b4-2e8f26858a4c + responses: + BadRequest: + description: Bad request. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Not found. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: Unauthorized. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Error' tags: - name: Operations description: Operational information about the museum. - name: Events - description: Special events hosted by the Museum + description: Special events hosted by the museum. - name: Tickets description: Museum tickets for general entrance or special events. security: diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 35669aa56..b839c6c12 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -8,7 +8,6 @@ const withAnalyzer = createBundleAnalyzer({ /** @type {import('next').NextConfig} */ const config = { - output: 'export', reactStrictMode: true, eslint: { // Replaced by root workspace command diff --git a/packages/openapi/src/render/playground.tsx b/packages/openapi/src/render/playground.tsx index 01ce4edb9..c1dc6b5d2 100644 --- a/packages/openapi/src/render/playground.tsx +++ b/packages/openapi/src/render/playground.tsx @@ -10,10 +10,6 @@ import { type ParsedSchema, } from '@/utils/schema'; import { getSecurities } from '@/utils/get-security'; -import { - createBrowserFetcher, - type FetchOptions, -} from '@/ui/playground/fetcher'; interface BaseRequestField { name: string; @@ -94,20 +90,7 @@ export interface APIPlaygroundProps { header?: PrimitiveRequestField[]; body?: RequestSchema; schemas: Record; - - fetchAction?: typeof fetch; -} - -async function fetch( - input: FetchOptions & { - bodySchema: RequestSchema | undefined; - references: Record; - }, -) { - 'use server'; - const baseFetcher = createBrowserFetcher(input.bodySchema, input.references); - - return baseFetcher.fetch(input); + proxyUrl?: string; } export function Playground({ @@ -154,7 +137,7 @@ export function Playground({ .map((v) => parameterToField(v, context)), body: bodySchema, schemas: context.references, - fetchAction: ctx.enableServerActionProxy ? fetch : undefined, + proxyUrl: ctx.proxyUrl, }; return ; diff --git a/packages/openapi/src/server/api-page.tsx b/packages/openapi/src/server/api-page.tsx index d35ce7f62..74dfb3334 100644 --- a/packages/openapi/src/server/api-page.tsx +++ b/packages/openapi/src/server/api-page.tsx @@ -8,14 +8,15 @@ import type { OpenAPIV3_1 } from 'openapi-types'; import type { NoReference } from '@/utils/schema'; import { type DocumentInput, processDocument } from '@/utils/process-document'; -export interface ApiPageProps - extends Pick< - RenderContext, - | 'generateCodeSamples' - | 'generateTypeScriptSchema' - | 'shikiOptions' - | 'enableServerActionProxy' - > { +type ApiPageContextProps = Pick< + Partial, + | 'shikiOptions' + | 'generateTypeScriptSchema' + | 'generateCodeSamples' + | 'proxyUrl' +>; + +export interface ApiPageProps extends ApiPageContextProps { document: DocumentInput; hasHead: boolean; @@ -110,16 +111,14 @@ export async function APIPage(props: ApiPageProps) { export async function getContext( { document, dereferenceMap }: ProcessedDocument, - options: Pick< - Partial, - 'shikiOptions' | 'generateTypeScriptSchema' | 'generateCodeSamples' - > & { + options: ApiPageContextProps & { renderer?: Partial; } = {}, ): Promise { return { document: document, dereferenceMap, + proxyUrl: options.proxyUrl, renderer: { ...createRenders(options.shikiOptions), ...options.renderer, diff --git a/packages/openapi/src/server/create.tsx b/packages/openapi/src/server/create.tsx index c8d865cf9..52447ccf9 100644 --- a/packages/openapi/src/server/create.tsx +++ b/packages/openapi/src/server/create.tsx @@ -1,6 +1,7 @@ import { type FC } from 'react'; import { APIPage, type ApiPageProps } from '@/server/api-page'; import type { DocumentInput } from '@/utils/process-document'; +import { createProxy } from '@/server/proxy'; export interface OpenAPIOptions extends Omit, 'document'> { @@ -12,10 +13,12 @@ export interface OpenAPIOptions export interface OpenAPIServer { APIPage: FC; + createProxy: typeof createProxy; } export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer { return { + createProxy, APIPage(props) { return ; }, diff --git a/packages/openapi/src/server/proxy.ts b/packages/openapi/src/server/proxy.ts new file mode 100644 index 000000000..1fd433f39 --- /dev/null +++ b/packages/openapi/src/server/proxy.ts @@ -0,0 +1,74 @@ +import type { NextRequest } from 'next/server'; + +const keys = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'] as const; + +type Proxy = { + [K in (typeof keys)[number]]: (req: NextRequest) => Promise; +}; + +export function createProxy(allowedUrls?: string[]): Proxy { + const handlers: Partial = {}; + + async function handler(req: NextRequest): Promise { + const url = req.nextUrl.searchParams.get('url'); + + if (!url) { + return Response.json( + 'A `url` query parameter is required for proxy url', + { + status: 400, + }, + ); + } + + if ( + allowedUrls && + allowedUrls.every((allowedUrl) => !allowedUrl.startsWith(url)) + ) { + return Response.json('The given `url` query parameter is not allowed', { + status: 400, + }); + } + + const clonedReq = new Request(url, req); + clonedReq.headers.forEach((_value, originalKey) => { + const key = originalKey.toLowerCase(); + const notAllowed = key === 'origin'; + + if (notAllowed) { + clonedReq.headers.delete(originalKey); + } + }); + + const res = await fetch(clonedReq, { + cache: 'no-cache', + }).catch((e) => new Error(e.toString())); + if (res instanceof Error) { + return Response.json(`Failed to proxy request: ${res.message}`, { + status: 400, + }); + } + + const headers = new Headers(res.headers); + headers.forEach((_value, originalKey) => { + const key = originalKey.toLowerCase(); + const notAllowed = key.startsWith('access-control-'); + + if (notAllowed) { + headers.delete(originalKey); + } + }); + headers.set('X-Forwarded-Host', res.url); + + return new Response(res.body, { + ...res, + headers, + }); + } + + for (const key of keys) { + handlers[key] = handler; + } + + return handlers as Proxy; +} diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 76822410d..1e79e55f2 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -38,7 +38,10 @@ type Awaitable = T | Promise; export type DereferenceMap = Map; export interface RenderContext { - enableServerActionProxy?: boolean; + /** + * The url of proxy to avoid CORS issues + */ + proxyUrl?: string; renderer: Renderer; diff --git a/packages/openapi/src/ui/playground/fetcher.ts b/packages/openapi/src/ui/playground/fetcher.ts index 2374dba6e..99246a60a 100644 --- a/packages/openapi/src/ui/playground/fetcher.ts +++ b/packages/openapi/src/ui/playground/fetcher.ts @@ -60,7 +60,7 @@ export function createBrowserFetcher( input.dynamicFields ?? new Map(), ) : undefined, - signal: AbortSignal.timeout(6000), + signal: AbortSignal.timeout(10 * 1000), }) .then(async (res) => { const contentType = res.headers.get('Content-Type') ?? ''; diff --git a/packages/openapi/src/ui/playground/index.tsx b/packages/openapi/src/ui/playground/index.tsx index 450ecd36a..5541251ad 100644 --- a/packages/openapi/src/ui/playground/index.tsx +++ b/packages/openapi/src/ui/playground/index.tsx @@ -18,7 +18,7 @@ import type { } from 'react-hook-form'; import { useApiContext } from '@/ui/contexts/api'; import { Form } from '@/ui/components/form'; -import type { Fetcher, FetchResult } from '@/ui/playground/fetcher'; +import type { FetchResult } from '@/ui/playground/fetcher'; import { getDefaultValue, getDefaultValues, @@ -64,7 +64,7 @@ export function APIPlayground({ body, fields = {}, schemas, - fetchAction, + proxyUrl, }: APIPlaygroundProps & { fields?: { auth?: CustomField<'authorization', PrimitiveRequestField>; @@ -87,23 +87,18 @@ export function APIPlayground({ }); const testQuery = useQuery(async (input: FormValues) => { - const fetcher: Fetcher = fetchAction - ? { - fetch(input) { - return fetchAction({ - ...input, - bodySchema: body, - references: schemas, - }); - }, - } - : await import('./fetcher').then((mod) => - mod.createBrowserFetcher(body, schemas), - ); - - const url = new URL( - `${baseUrl ?? window.location.origin}${createUrlFromInput(route, input.path, input.query)}`, + const fetcher = await import('./fetcher').then((mod) => + mod.createBrowserFetcher(body, schemas), ); + const targetUrl = `${baseUrl ?? window.location.origin}${createPathnameFromInput(route, input.path, input.query)}`; + + let url: URL; + if (proxyUrl) { + url = new URL(proxyUrl, window.location.origin); + url.searchParams.append('url', targetUrl); + } else { + url = new URL(targetUrl); + } const header = { ...input.header }; @@ -268,7 +263,7 @@ export function APIPlayground({ ); } -function createUrlFromInput( +function createPathnameFromInput( route: string, path: Record, query: Record, @@ -300,7 +295,7 @@ function RouteDisplay({ route }: { route: string }): ReactElement { }); const pathname = useMemo( - () => createUrlFromInput(route, path, query), + () => createPathnameFromInput(route, path, query), [route, path, query], );