From 0c9d0c41afa9bdbc2ee554da2a7d0f3b2a5d328a Mon Sep 17 00:00:00 2001 From: aim4ik11 Date: Mon, 22 Dec 2025 15:19:01 +0200 Subject: [PATCH 1/5] switch to next realm version to get kv storage --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 945000c2..bfbc9cee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@redocly/marketing-pages": "0.1.43", - "@redocly/realm": "0.128.0", + "@redocly/realm": "next", "buffer": "^6.0.3", "highlight-words-core": "^1.2.3", "path": "^0.12.7", diff --git a/package.json b/package.json index c597d02a..b7e25bcd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "license": "UNLICENSED", "dependencies": { "@redocly/marketing-pages": "0.1.43", - "@redocly/realm": "0.128.0", + "@redocly/realm": "next", "buffer": "^6.0.3", "highlight-words-core": "^1.2.3", "path": "^0.12.7", From 7a1d67a7c9e645673412fae03d81e533521cb3f8 Mon Sep 17 00:00:00 2001 From: aim4ik11 Date: Mon, 22 Dec 2025 15:19:10 +0200 Subject: [PATCH 2/5] add api function --- @api/page-rss-feed.get.ts | 229 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 @api/page-rss-feed.get.ts diff --git a/@api/page-rss-feed.get.ts b/@api/page-rss-feed.get.ts new file mode 100644 index 00000000..e77eec1f --- /dev/null +++ b/@api/page-rss-feed.get.ts @@ -0,0 +1,229 @@ +import type { ApiFunctionsContext } from '@redocly/config'; +import { escapeXml, formatRssDate } from '../@theme/utils/rss'; + +const TARGET_PAGE_SLUG = '/docs/end-user/interact-with-pages'; + +interface PageData { + props?: { + frontmatter?: { + title?: string; + description?: string; + [key: string]: unknown; + }; + seo?: { + title?: string; + description?: string; + [key: string]: unknown; + }; + ast?: unknown; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface PageChangeRecord { + hash: string; + timestamp: number; + pageTitle: string; + pageUrl: string; +} + +async function calculateHash(pageData: PageData): Promise { + const dataToHash = pageData?.props?.ast || pageData?.props || pageData; + const dataString = JSON.stringify(dataToHash); + + if (typeof crypto !== 'undefined' && crypto.subtle) { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(dataString); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 16); + } catch (error) { + // Continue to fallback hash + } + } + + let hash = 0; + for (let i = 0; i < dataString.length; i++) { + const char = dataString.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + + return hash.toString(36); +} + +function getPageTitle(pageData: PageData, slug: string): string { + return ( + pageData?.props?.seo?.title || + pageData?.props?.frontmatter?.title || + slug.split('/').filter(Boolean).pop() || + 'Untitled Page' + ); +} + +function buildRssItem(record: PageChangeRecord, baseUrl: string): string { + const title = `${escapeXml(record.pageTitle)} was updated`; + const link = `${baseUrl}${record.pageUrl}`; + const guid = `${link}#${record.hash}`; + const pubDate = formatRssDate(record.timestamp); + const description = `${escapeXml(record.pageTitle)} was updated at ${new Date(record.timestamp).toLocaleString()}`; + + return ` + + ${title} + ${escapeXml(link)} + ${escapeXml(guid)} + ${pubDate} + ${escapeXml(description)} + + `; +} + +async function fetchPageData(baseUrl: string, pageSlug: string): Promise { + const pageDataPath = pageSlug.replace(/^\//, '').replace(/\/$/, ''); + const pageDataUrl = `${baseUrl}/page-data/${pageDataPath}/data.json`; + + try { + const pageDataResponse = await fetch(pageDataUrl); + + if (pageDataResponse.ok) { + return await pageDataResponse.json(); + } + + throw new Error(`Failed to fetch page data: ${pageDataResponse.status} ${pageDataResponse.statusText}`); + } catch (error) { + throw new Error(`Failed to fetch page data: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +async function updatePageChanges( + kv: any, + pageDataPath: string, + currentHash: string, + pageTitle: string, + pageUrl: string +): Promise { + const lastHashKey = ['pageRss', 'pages', pageDataPath, 'lastHash']; + const lastHashRecord = await kv.get(lastHashKey); + const lastHash = lastHashRecord?.value; + + if (lastHash !== currentHash) { + const now = Date.now(); + + await kv.set(lastHashKey, currentHash); + + const changeRecord: PageChangeRecord = { + hash: currentHash, + timestamp: now, + pageTitle, + pageUrl, + }; + + const recordKey = [ + 'pageRss', + 'changes', + pageDataPath, + now.toString().padStart(20, '0'), + currentHash, + ]; + + await kv.set(recordKey, changeRecord); + } +} + +async function getChangeRecords( + kv: any, + pageDataPath: string, + currentHash: string, + pageTitle: string, + pageUrl: string +): Promise { + const changesPrefix = ['pageRss', 'changes', pageDataPath]; + const changesResult = await kv.list( + { prefix: changesPrefix }, + { limit: 50, reverse: true } + ); + + if (changesResult.items.length > 0) { + return changesResult.items.map((item) => item.value); + } + + return [ + { + hash: currentHash, + timestamp: Date.now(), + pageTitle, + pageUrl, + }, + ]; +} + +function buildRssFeed( + pageTitle: string, + pageUrl: string, + baseUrl: string, + feedUrl: string, + records: PageChangeRecord[] +): string { + const rssItems = records.map((record) => buildRssItem(record, baseUrl)).join(''); + + return ` + + + ${escapeXml(pageTitle)} - Update Feed + ${escapeXml(`${baseUrl}${pageUrl}`)} + RSS feed for changes to ${escapeXml(pageTitle)} + en-us + ${formatRssDate(Date.now())} + + ${rssItems} + +`; +} + +export default async function pageRssFeedHandler( + request: Request, + context: ApiFunctionsContext +) { + try { + // @ts-ignore - getKv may not be in type definitions yet but exists at runtime + const kv = await context.getKv(); + const url = new URL(request.url); + const baseUrl = `${url.protocol}//${url.host}`; + + const pageData = await fetchPageData(baseUrl, TARGET_PAGE_SLUG); + const pageDataPath = TARGET_PAGE_SLUG.replace(/^\//, '').replace(/\/$/, ''); + + const currentHash = await calculateHash(pageData); + const pageTitle = getPageTitle(pageData, TARGET_PAGE_SLUG); + + await updatePageChanges(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG); + const records = await getChangeRecords(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG); + + const rssXml = buildRssFeed(pageTitle, TARGET_PAGE_SLUG, baseUrl, request.url, records); + + return new Response(rssXml, { + status: 200, + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + }); + } catch (error: any) { + if (error?.message.includes('Failed to fetch page data')) { + return context.status(404).json({ + error: 'Page not found', + message: error.message, + }); + } + + return context.status(500).json({ + error: 'Internal server error', + message: 'Failed to generate RSS feed', + }); + } +} From 0c34f3cda5fbc1b9582b4bdf9fc6f2eac84467b0 Mon Sep 17 00:00:00 2001 From: aim4ik11 Date: Mon, 22 Dec 2025 15:38:32 +0200 Subject: [PATCH 3/5] update hashing --- @api/page-rss-feed.get.ts | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/@api/page-rss-feed.get.ts b/@api/page-rss-feed.get.ts index e77eec1f..60ad3ecf 100644 --- a/@api/page-rss-feed.get.ts +++ b/@api/page-rss-feed.get.ts @@ -1,5 +1,6 @@ import type { ApiFunctionsContext } from '@redocly/config'; import { escapeXml, formatRssDate } from '../@theme/utils/rss'; +import crypto from 'crypto'; const TARGET_PAGE_SLUG = '/docs/end-user/interact-with-pages'; @@ -32,26 +33,7 @@ async function calculateHash(pageData: PageData): Promise { const dataToHash = pageData?.props?.ast || pageData?.props || pageData; const dataString = JSON.stringify(dataToHash); - if (typeof crypto !== 'undefined' && crypto.subtle) { - try { - const encoder = new TextEncoder(); - const data = encoder.encode(dataString); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 16); - } catch (error) { - // Continue to fallback hash - } - } - - let hash = 0; - for (let i = 0; i < dataString.length; i++) { - const char = dataString.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - - return hash.toString(36); + return crypto.createHash('sha1').update(dataString).digest('base64').substring(0, 16); } function getPageTitle(pageData: PageData, slug: string): string { From 81defb8b9fadca7a6c1d731887d25e6ba7fe7a0d Mon Sep 17 00:00:00 2001 From: aim4ik11 Date: Mon, 22 Dec 2025 16:24:59 +0200 Subject: [PATCH 4/5] improve error handling, add pubdate --- @api/page-rss-feed.get.ts | 55 +++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/@api/page-rss-feed.get.ts b/@api/page-rss-feed.get.ts index 60ad3ecf..906193ef 100644 --- a/@api/page-rss-feed.get.ts +++ b/@api/page-rss-feed.get.ts @@ -17,6 +17,7 @@ interface PageData { [key: string]: unknown; }; ast?: unknown; + lastModified?: number | string; [key: string]: unknown; }; [key: string]: unknown; @@ -70,13 +71,16 @@ async function fetchPageData(baseUrl: string, pageSlug: string): Promise { const lastHashKey = ['pageRss', 'pages', pageDataPath, 'lastHash']; const lastHashRecord = await kv.get(lastHashKey); const lastHash = lastHashRecord?.value; - if (lastHash !== currentHash) { - const now = Date.now(); + if (lastHash === currentHash) { + return; + } - await kv.set(lastHashKey, currentHash); + const changesPrefix = ['pageRss', 'changes', pageDataPath]; + const existingChanges = await kv.list( + { prefix: changesPrefix }, + { limit: 100 } + ) as { items: Array<{ value: PageChangeRecord }> }; + + const hashAlreadyExists = existingChanges.items.some( + (item) => item.value?.hash === currentHash + ); + + if (!hashAlreadyExists) { + let timestamp: number; + if (pageLastModified) { + timestamp = typeof pageLastModified === 'string' + ? new Date(pageLastModified).getTime() + : pageLastModified; + } else { + timestamp = Date.now(); + } const changeRecord: PageChangeRecord = { hash: currentHash, - timestamp: now, + timestamp, pageTitle, pageUrl, }; @@ -107,12 +131,14 @@ async function updatePageChanges( 'pageRss', 'changes', pageDataPath, - now.toString().padStart(20, '0'), + timestamp.toString().padStart(20, '0'), currentHash, ]; await kv.set(recordKey, changeRecord); } + + await kv.set(lastHashKey, currentHash); } async function getChangeRecords( @@ -180,8 +206,9 @@ export default async function pageRssFeedHandler( const currentHash = await calculateHash(pageData); const pageTitle = getPageTitle(pageData, TARGET_PAGE_SLUG); + const pageLastModified = pageData?.props?.lastModified; - await updatePageChanges(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG); + await updatePageChanges(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG, pageLastModified); const records = await getChangeRecords(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG); const rssXml = buildRssFeed(pageTitle, TARGET_PAGE_SLUG, baseUrl, request.url, records); @@ -196,16 +223,16 @@ export default async function pageRssFeedHandler( }, }); } catch (error: any) { - if (error?.message.includes('Failed to fetch page data')) { + if (error?.message?.includes('Page not found') || error?.message?.includes('404')) { return context.status(404).json({ error: 'Page not found', - message: error.message, + message: error.message || 'Could not fetch page data', }); } return context.status(500).json({ error: 'Internal server error', - message: 'Failed to generate RSS feed', + message: error?.message || 'Failed to generate RSS feed', }); } } From 363d10d68d64a5b9f949816f64661ac84ec9e611 Mon Sep 17 00:00:00 2001 From: aim4ik11 Date: Tue, 23 Dec 2025 11:25:53 +0200 Subject: [PATCH 5/5] support for all pages --- @api/page-rss-feed.get.ts | 58 +++++++++-- @api/page-rss-pages.yaml | 201 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 @api/page-rss-pages.yaml diff --git a/@api/page-rss-feed.get.ts b/@api/page-rss-feed.get.ts index 906193ef..43ba6132 100644 --- a/@api/page-rss-feed.get.ts +++ b/@api/page-rss-feed.get.ts @@ -1,8 +1,17 @@ import type { ApiFunctionsContext } from '@redocly/config'; import { escapeXml, formatRssDate } from '../@theme/utils/rss'; import crypto from 'crypto'; +// @ts-ignore +import pagesConfig from './page-rss-pages.yaml'; -const TARGET_PAGE_SLUG = '/docs/end-user/interact-with-pages'; +interface PageConfig { + slug: string; + title?: string; +} + +interface PagesConfig { + pages: PageConfig[]; +} interface PageData { props?: { @@ -172,11 +181,14 @@ function buildRssFeed( pageTitle: string, pageUrl: string, baseUrl: string, - feedUrl: string, + apiPath: string, + pageSlug: string, records: PageChangeRecord[] ): string { const rssItems = records.map((record) => buildRssItem(record, baseUrl)).join(''); + const feedUrl = `${baseUrl}${apiPath}?page=${encodeURIComponent(pageSlug)}`; + return ` @@ -191,27 +203,49 @@ function buildRssFeed( `; } +function getPageSlug(context: ApiFunctionsContext, config: PagesConfig): string { + const pageSlug = context.query.page as string | undefined; + + if (pageSlug) { + const isValidPage = config.pages.some((p) => p.slug === pageSlug); + if (!isValidPage) { + throw new Error(`Page '${pageSlug}' is not in the allowed pages list`); + } + return pageSlug; + } + + if (config.pages.length === 0) { + throw new Error('No pages configured in page-rss-pages.yaml'); + } + + return config.pages[0].slug; +} + export default async function pageRssFeedHandler( request: Request, context: ApiFunctionsContext ) { try { + const config = pagesConfig as PagesConfig; + const pageSlug = getPageSlug(context, config); + // @ts-ignore - getKv may not be in type definitions yet but exists at runtime const kv = await context.getKv(); const url = new URL(request.url); const baseUrl = `${url.protocol}//${url.host}`; + const apiPath = url.pathname; - const pageData = await fetchPageData(baseUrl, TARGET_PAGE_SLUG); - const pageDataPath = TARGET_PAGE_SLUG.replace(/^\//, '').replace(/\/$/, ''); + const pageData = await fetchPageData(baseUrl, pageSlug); + const pageDataPath = pageSlug.replace(/^\//, '').replace(/\/$/, ''); const currentHash = await calculateHash(pageData); - const pageTitle = getPageTitle(pageData, TARGET_PAGE_SLUG); + const pageTitle = getPageTitle(pageData, pageSlug); const pageLastModified = pageData?.props?.lastModified; - await updatePageChanges(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG, pageLastModified); - const records = await getChangeRecords(kv, pageDataPath, currentHash, pageTitle, TARGET_PAGE_SLUG); + await updatePageChanges(kv, pageDataPath, currentHash, pageTitle, pageSlug, pageLastModified); + const records = await getChangeRecords(kv, pageDataPath, currentHash, pageTitle, pageSlug); - const rssXml = buildRssFeed(pageTitle, TARGET_PAGE_SLUG, baseUrl, request.url, records); + const rssXml = buildRssFeed(pageTitle, pageSlug, baseUrl, apiPath, pageSlug, records); return new Response(rssXml, { status: 200, @@ -230,6 +264,14 @@ export default async function pageRssFeedHandler( }); } + if (error?.message?.includes('not in the allowed pages list')) { + return context.status(400).json({ + error: 'Invalid page', + message: error.message, + availablePages: (pagesConfig as PagesConfig).pages.map((p) => p.slug), + }); + } + return context.status(500).json({ error: 'Internal server error', message: error?.message || 'Failed to generate RSS feed', diff --git a/@api/page-rss-pages.yaml b/@api/page-rss-pages.yaml new file mode 100644 index 00000000..008a8182 --- /dev/null +++ b/@api/page-rss-pages.yaml @@ -0,0 +1,201 @@ +pages: + - slug: '/docs' + title: 'docs' + - slug: '/billing/signup' + title: 'signup' + - slug: '/billing/w-9' + title: 'w-9' + - slug: '/code-of-conduct' + title: 'code-of-conduct' + - slug: '/gsod-casestudy' + title: 'gsod-casestudy' + - slug: '/gsod-2022' + title: 'gsod-2022' + - slug: '/gsod' + title: 'gsod' + - slug: '/cookie-notice' + title: 'cookie-notice' + - slug: '/policies/pandemic-plan' + title: 'pandemic-plan' + - slug: '/dpa' + title: 'dpa' + - slug: '/legal/privacy-notice-2020-07-18' + title: 'privacy-notice-2020-07-18' + - slug: '/privacy-notice' + title: 'privacy-notice' + - slug: '/sla-2021-07-18' + title: 'sla-2021-07-18' + - slug: '/sla' + title: 'sla' + - slug: '/legal/subscription-agreement-2021-11-11' + title: 'subscription-agreement-2021-11-11' + - slug: '/subscription-agreement-2024-01-26' + title: 'subscription-agreement-2024-01-26' + - slug: '/subscription-agreement' + title: 'subscription-agreement' + - slug: '/vulnerability-disclosure-policy' + title: 'vulnerability-disclosure-policy' + - slug: '/product-timelines' + title: 'product-timelines' + - slug: '/products' + title: 'products' + - slug: '/resources' + title: 'resources' + - slug: '/roadmap' + title: 'roadmap' + - slug: '/sub-processors' + title: 'sub-processors' + - slug: '/learn/ai-for-docs/ai-modern-api-docs' + title: 'ai-modern-api-docs' + - slug: '/learn/ai-for-docs/ai-reviews' + title: 'ai-reviews' + - slug: '/learn/ai-for-docs/ai-usability-testing' + title: 'ai-usability-testing' + - slug: '/learn/arazzo/arazzo-basics' + title: 'arazzo-basics' + - slug: '/learn/arazzo/arazzo-walkthrough' + title: 'arazzo-walkthrough' + - slug: '/learn/arazzo/documenting-api-workflows' + title: 'documenting-api-workflows' + - slug: '/learn/arazzo/documenting-multiple-apis-using-arazzo' + title: 'documenting-multiple-apis-using-arazzo' + - slug: '/learn/arazzo/linting-arazzo-workflows' + title: 'linting-arazzo-workflows' + - slug: '/learn/arazzo/source-descriptions-and-refs' + title: 'source-descriptions-and-refs' + - slug: '/learn/arazzo/success-criteria-and-failure-handling' + title: 'success-criteria-and-failure-handling' + - slug: '/learn/arazzo/testing-arazzo-workflows' + title: 'testing-arazzo-workflows' + - slug: '/learn/arazzo/understanding-workflows-and-steps' + title: 'understanding-workflows-and-steps' + - slug: '/learn/arazzo/what-is-arazzo' + title: 'what-is-arazzo' + - slug: '/learn/arazzo/why-arazzo-matters' + title: 'why-arazzo-matters' + - slug: '/learn/markdoc/debug-markdoc-variables' + title: 'debug-markdoc-variables' + - slug: '/learn/markdoc/evaluating-markdoc' + title: 'evaluating-markdoc' + - slug: '/learn/markdoc' + title: 'markdoc' + - slug: '/learn/markdoc/write-with-markdoc' + title: 'write-with-markdoc' + - slug: '/learn/openapi/all-of' + title: 'all-of' + - slug: '/learn/openapi/any-of-one-of' + title: 'any-of-one-of' + - slug: '/learn/openapi/discriminator' + title: 'discriminator' + - slug: '/learn/openapi/learning-openapi' + title: 'learning-openapi' + - slug: '/learn/openapi/multi-file-definitions' + title: 'multi-file-definitions' + - slug: '/learn/openapi/openapi-decisions' + title: 'openapi-decisions' + - slug: '/learn/openapi/ref-guide' + title: 'ref-guide' + - slug: '/learn/security/api-input-validation-injection-prevention' + title: 'api-input-validation-injection-prevention' + - slug: '/learn/security/api-rate-limiting-abuse-prevention' + title: 'api-rate-limiting-abuse-prevention' + - slug: '/learn/security/api-tls-encryption-https-best-practices' + title: 'api-tls-encryption-https-best-practices' + - slug: '/learn/security/authentication-authorization-openapi' + title: 'authentication-authorization-openapi' + - slug: '/learn/security' + title: 'security' + - slug: '/learn/testing/contract-testing-101' + title: 'contract-testing-101' + - slug: '/learn/testing' + title: 'testing' + - slug: '/learn/testing/tools-for-api-testing-in-2025' + title: 'tools-for-api-testing-in-2025' + - slug: '/learn/yaml/blocks-and-flows' + title: 'blocks-and-flows' + - slug: '/learn/yaml/documents-comments' + title: 'documents-comments' + - slug: '/learn/yaml' + title: 'yaml' + - slug: '/learn/yaml/maps' + title: 'maps' + - slug: '/learn/yaml/scalars' + title: 'scalars' + - slug: '/learn/yaml/sequences' + title: 'sequences' + - slug: '/learn/yaml/troubleshooting' + title: 'troubleshooting' + - slug: '/learn/yaml/yaml-or-json' + title: 'yaml-or-json' + - slug: '/learn/openapi/openapi-visual-reference/contributing' + title: 'contributing' + - slug: '/learn/openapi/openapi-visual-reference/array' + title: 'array' + - slug: '/learn/openapi/openapi-visual-reference/boolean' + title: 'boolean' + - slug: '/learn/openapi/openapi-visual-reference/callbacks' + title: 'callbacks' + - slug: '/learn/openapi/openapi-visual-reference/components' + title: 'components' + - slug: '/learn/openapi/openapi-visual-reference/contact' + title: 'contact' + - slug: '/learn/openapi/openapi-visual-reference/discriminator' + title: 'discriminator' + - slug: '/learn/openapi/openapi-visual-reference/encoding' + title: 'encoding' + - slug: '/learn/openapi/openapi-visual-reference/example' + title: 'example' + - slug: '/learn/openapi/openapi-visual-reference/examples' + title: 'examples' + - slug: '/learn/openapi/openapi-visual-reference/external-docs' + title: 'external-docs' + - slug: '/learn/openapi/openapi-visual-reference/header' + title: 'header' + - slug: '/learn/openapi/openapi-visual-reference' + title: 'openapi-visual-reference' + - slug: '/learn/openapi/openapi-visual-reference/info' + title: 'info' + - slug: '/learn/openapi/openapi-visual-reference/integer' + title: 'integer' + - slug: '/learn/openapi/openapi-visual-reference/license' + title: 'license' + - slug: '/learn/openapi/openapi-visual-reference/links' + title: 'links' + - slug: '/learn/openapi/openapi-visual-reference/media-type' + title: 'media-type' + - slug: '/learn/openapi/openapi-visual-reference/named-path-items' + title: 'named-path-items' + - slug: '/learn/openapi/openapi-visual-reference/named-request-bodies' + title: 'named-request-bodies' + - slug: '/learn/openapi/openapi-visual-reference/named-responses' + title: 'named-responses' + - slug: '/learn/openapi/openapi-visual-reference/null' + title: 'null' + - slug: '/learn/openapi/openapi-visual-reference/number' + title: 'number' + - slug: '/learn/openapi/openapi-visual-reference/oauth-flows' + title: 'oauth-flows' + - slug: '/learn/openapi/openapi-visual-reference/object' + title: 'object' + - slug: '/learn/openapi/openapi-visual-reference/openapi-node-types' + title: 'openapi-node-types' + - slug: '/learn/openapi/openapi-visual-reference/openapi-1' + title: 'openapi-1' + - slug: '/learn/openapi/openapi-visual-reference/operation' + title: 'operation' + - slug: '/learn/openapi/openapi-visual-reference/parameter' + title: 'parameter' + - slug: '/learn/openapi/openapi-visual-reference/parameters' + title: 'parameters' + - slug: '/learn/openapi/openapi-visual-reference/path-item' + title: 'path-item' + - slug: '/learn/openapi/openapi-visual-reference/paths' + title: 'paths' + - slug: '/learn/openapi/openapi-visual-reference/reference' + title: 'reference' + - slug: '/learn/openapi/openapi-visual-reference/request-body' + title: 'request-body' + - slug: '/learn/openapi/openapi-visual-reference/response' + title: 'response' + - slug: '/learn/openapi/openapi-visual-reference/responses' + title: 'responses'