From af86d5b0d602ce20c2c7a6dac8bfd2a5235b7b50 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 29 Aug 2026 12:30:16 +0100 Subject: [PATCH 1/2] feat: semantically announce marketplace loading, error, and page updates --- src/pages/MarketplacePage.tsx | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index 804e8366..d5f7b817 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -386,6 +386,39 @@ export default function MarketplacePage(): JSX.Element { ]); // ── Aria-live announcements (relies on filtered being defined above) ── + const prevIsLoading = useRef(isLoading); + const prevFetchError = useRef(fetchError); + const prevPageIndex = useRef(currentPageIndex); + + useEffect(() => { + if (prevIsLoading.current && !isLoading) { + if (fetchError) { + announce(`Error loading marketplace: ${fetchError}`); + } else { + announce("Marketplace loaded."); + } + } else if (!prevIsLoading.current && isLoading) { + announce("Loading marketplace..."); + } + prevIsLoading.current = isLoading; + }, [isLoading, fetchError, announce]); + + useEffect(() => { + if (fetchError && fetchError !== prevFetchError.current && !isLoading) { + announce(`Error loading marketplace: ${fetchError}`); + } + prevFetchError.current = fetchError; + }, [fetchError, isLoading, announce]); + + useEffect(() => { + if (prevPageIndex.current !== currentPageIndex) { + const newStart = totalItemCount > 0 ? currentPageIndex * pageSize + 1 : 0; + const newEnd = Math.min((currentPageIndex + 1) * pageSize, totalItemCount); + announce(`Page changed. Showing APIs ${newStart} through ${newEnd}.`); + prevPageIndex.current = currentPageIndex; + } + }, [currentPageIndex, pageSize, totalItemCount, announce]); + const prevFilteredCount = useRef(0); const isFirstFilterRender = useRef(true); useEffect(() => { @@ -490,7 +523,12 @@ export default function MarketplacePage(): JSX.Element { ); if (isLoading) { - return ; + return ( + <> + + + + ); } return ( From dcfe8ddfffa291c5078c0e821ca39f49a39aa704 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 20 Oct 2026 03:25:41 +0100 Subject: [PATCH 2/2] fix(rate-limit): per-API rate limit page --- src/App.tsx | 17 +- src/config/routeCatalogue.ts | 5 - src/data/mockApis.ts | 29 ++++ src/pages/ApiDetailPage.tsx | 1 + src/pages/RateLimitCard.test.tsx | 236 +++++++++++---------------- src/pages/RateLimitCard.tsx | 265 ++++++++++++++++--------------- src/renderRoute.tsx | 3 +- src/routeMatchers.test.ts | 16 ++ src/routeMatchers.ts | 16 ++ 9 files changed, 308 insertions(+), 280 deletions(-) create mode 100644 src/routeMatchers.test.ts create mode 100644 src/routeMatchers.ts diff --git a/src/App.tsx b/src/App.tsx index 6e6f2081..c5c2f6ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback, lazy, Suspense } from "react"; -import { Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom"; +import { Routes, Route, NavLink, Navigate, useNavigate, useLocation } from "react-router-dom"; +import { useToast } from "./components/Toast"; import { ThemeToggle } from "./ThemeToggle"; import ServerError from "./components/ServerError"; import useDocumentTitle from "./hooks/useDocumentTitle"; @@ -47,7 +48,6 @@ const routePrefetchers: Record Promise> = { "/theme-playground": () => import("./pages/ThemePlayground"), "/design-system/docs": () => import("./pages/DesignSystemDocs"), "/a11y-audit": () => import("./pages/A11yAudit"), - "/rate-limit": () => import("./pages/RateLimitCard"), "/webhooks/deliveries": () => import("./pages/WebhookDeliveries"), "/endpoints": () => import("./pages/EndpointSummary"), }; @@ -163,7 +163,8 @@ const APP_ROUTES = { themePlayground: "/theme-playground", designSystem: "/design-system/docs", serverError: "/500", - rateLimitCard: "/rate-limit", + rateLimitCard: "/marketplace/:apiId/rate-limits", + legacyRateLimit: "/rate-limit", slaCard: "/marketplace/grantfox-wave-compute/sla", webhookDeliveries: "/webhooks/deliveries", onboarding: "/onboarding", @@ -179,6 +180,15 @@ const PRIMARY_NAV: Array<{ to: string; label: string }> = [ { to: APP_ROUTES.documentation, label: "Docs" }, ]; +/** `/rate-limit` no longer exists; limits are per-API. Send users to the Marketplace. */ +function LegacyRateLimitRedirect() { + const { showToast } = useToast(); + useEffect(() => { + showToast("Rate limits are now shown per API. Pick an API to see its limits."); + }, [showToast]); + return ; +} + function buildExplorerLink(hash: string) { return `${EXPLORER_BASE_URL}${hash}`; } @@ -853,6 +863,7 @@ function App() { } /> } /> + } /> } /> diff --git a/src/config/routeCatalogue.ts b/src/config/routeCatalogue.ts index 4fc9901d..dabac52d 100644 --- a/src/config/routeCatalogue.ts +++ b/src/config/routeCatalogue.ts @@ -75,11 +75,6 @@ export const ROUTE_CATALOGUE: RouteCatalogueEntry[] = [ path: "/design-system/docs", keywords: ["design system", "design", "components", "ui kit"], }, - { - label: "Rate Limit", - path: "/rate-limit", - keywords: ["rate limit", "throttle", "limits"], - }, { label: "Webhook Deliveries", path: "/webhooks/deliveries", diff --git a/src/data/mockApis.ts b/src/data/mockApis.ts index 987fb109..0ff16699 100644 --- a/src/data/mockApis.ts +++ b/src/data/mockApis.ts @@ -36,6 +36,12 @@ export type APIItem = { sparklineValues?: number[]; pricingOnRequest?: boolean; baseUrl?: string; + rateLimits?: Array<{ + plan: string; + requestsPerMinute: number; + burstLimit?: number; + concurrentConnections?: number; + }>; }; @@ -98,9 +104,27 @@ const SAMPLE_APIS: Array<{ endpoints: [{ id: "render-pdf", title: "Render PDF", url: "/v1/render", method: "POST", group: "Rendering", params: [{ name: "html", type: "string", required: true }, { name: "page_size", type: "string", required: false }], response: '{ "pdf_url": "https://files.pdfbuilder.dev/r/91c.pdf", "pages": 3 }' }] }, ]; +/** Published rate limits for sample APIs; APIs absent here publish none. */ +const SAMPLE_RATE_LIMITS: Record = { + "geocode-01": [ + { plan: "Free", requestsPerMinute: 30, burstLimit: 60 }, + { plan: "Pro", requestsPerMinute: 1200, burstLimit: 2400 }, + ], + "fx-rates-01": [ + { plan: "Free", requestsPerMinute: 60, concurrentConnections: 2 }, + { plan: "Developer", requestsPerMinute: 600, concurrentConnections: 10 }, + { plan: "Enterprise", requestsPerMinute: 12000, concurrentConnections: 200 }, + ], +}; + export const MOCK_APIS: APIItem[] = [ { id: "weather-001", + rateLimits: [ + { plan: "Free", requestsPerMinute: 10, burstLimit: 20, concurrentConnections: 2 }, + { plan: "Developer", requestsPerMinute: 120, burstLimit: 200, concurrentConnections: 10 }, + { plan: "Pro", requestsPerMinute: 600, burstLimit: 1000, concurrentConnections: 50 }, + ], name: "WeatherSim API", provider: { name: "Acme Labs", url: "#" }, version: "2.3.1", @@ -180,6 +204,10 @@ export const MOCK_APIS: APIItem[] = [ }, { id: "pay-qr", + rateLimits: [ + { plan: "Standard", requestsPerMinute: 300 }, + { plan: "Premium", requestsPerMinute: 3000 }, + ], name: "QuickPay", provider: { name: "PayFast", url: "#" }, status: "degraded", @@ -356,6 +384,7 @@ export const MOCK_APIS: APIItem[] = [ endpoints: x.endpoints, stats: { totalCalls: x.calls, avgResponseMs: x.latency, uptimePct: x.uptime }, hourlyHealth: health(-1), + rateLimits: SAMPLE_RATE_LIMITS[x.id], sparklineValues: [10 + i, 12 + i, 11 + i, 14 + i, 16 + i, 15 + i, 18 + i, 19 + i, 17 + i, 21 + i, 22 + i, 24 + i], } satisfies APIItem; }), diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index d6166940..b144ebf0 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -1251,6 +1251,7 @@ export default function ApiDetailPage({ onBack }: Props) { Status + View rate limits diff --git a/src/pages/RateLimitCard.test.tsx b/src/pages/RateLimitCard.test.tsx index bbac4b62..13515391 100644 --- a/src/pages/RateLimitCard.test.tsx +++ b/src/pages/RateLimitCard.test.tsx @@ -1,172 +1,120 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; -import { MemoryRouter } from "react-router-dom"; -import RateLimitCard from "./RateLimitCard"; - -/** - * RateLimitCard tests – issue #567 (GrantFox FWC26 "Stellar Wave") - * - * Covers: - * 1. Page structure and accessibility - * 2. Breadcrumb presence and middle-ellipsis truncation - * 3. Rate-limit table content - * 4. Color-blind safe patterns on plan badges - */ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { APIItem } from "../data/mockApis"; +import { isMarketplaceSubpage } from "../routeMatchers"; +import { ROUTE_CATALOGUE } from "../config/routeCatalogue"; + +const catalog: APIItem[] = [ + { + id: "three-tier", + name: "Three Tier API", + provider: { name: "P" }, + description: "d", + pricePerRequest: 0.01, + rateLimits: [ + { plan: "Free", requestsPerMinute: 10, burstLimit: 20, concurrentConnections: 2 }, + { plan: "Developer", requestsPerMinute: 120000, burstLimit: 200, concurrentConnections: 10 }, + { plan: "Pro", requestsPerMinute: 600, burstLimit: 1000, concurrentConnections: 50 }, + ], + }, + { + id: "no-burst", + name: "No Burst API", + provider: { name: "P" }, + description: "d", + pricePerRequest: 0.01, + rateLimits: [{ plan: "Standard", requestsPerMinute: 300 }], + }, + { + id: "unpublished", + name: "Unpublished API", + provider: { name: "P" }, + description: "d", + pricePerRequest: 0.01, + }, +]; + +vi.mock("../api/catalogApi", () => ({ + fetchCatalog: vi.fn(() => Promise.resolve(catalog)), +})); -/** Expected pattern class for each plan. */ -const PLAN_PATTERN_CLASSES: Record = { - Free: "free", - Developer: "developer", - Pro: "pro", - Enterprise: "enterprise", -}; +import RateLimitCard from "./RateLimitCard"; -function renderPage() { +function renderPage(apiId: string) { return render( - - + + + } /> + , ); } describe("RateLimitCard", () => { - afterEach(() => { - cleanup(); - }); - - // ── Page structure ──────────────────────────────────────────────────────── - - it("renders the page heading", () => { - renderPage(); - expect( - screen.getByRole("heading", { name: /rate limit configuration/i }), - ).toBeTruthy(); - }); + beforeEach(() => vi.clearAllMocks()); + afterEach(() => cleanup()); - it("renders the API subtitle", () => { - renderPage(); - expect( - screen.getByText(/GrantFox Wave Compute API/), - ).toBeTruthy(); + it("shows a loading state first", () => { + renderPage("three-tier"); + expect(screen.getByRole("status").textContent).toMatch(/loading/i); }); - // ── Breadcrumb ──────────────────────────────────────────────────────────── - - it("renders an accessible breadcrumb navigation", () => { - renderPage(); - const nav = screen.getByRole("navigation", { name: /breadcrumb/i }); - expect(nav).toBeTruthy(); - }); - - it("shows Marketplace as the first breadcrumb link", () => { - renderPage(); - const marketplaceLink = screen.getByRole("link", { - name: "Marketplace", - }); - expect(marketplaceLink).toBeTruthy(); - expect(marketplaceLink.getAttribute("href")).toBe("/marketplace"); + it("renders three rows and a caption naming the API", async () => { + renderPage("three-tier"); + const table = await screen.findByRole("table", { name: /Three Tier API/ }); + expect(within(table).getByText(/Rate limit tiers for Three Tier API/)).toBeTruthy(); + expect(within(table).getAllByRole("row").length).toBe(4); // header + 3 + expect(within(table).getByText(new Intl.NumberFormat().format(120000))).toBeTruthy(); + expect(within(table).getByText("Burst limit")).toBeTruthy(); + expect(within(table).getByText("Concurrent connections")).toBeTruthy(); }); - it("marks the last crumb as the current page", () => { - renderPage(); - const current = document.querySelector( - '[aria-current="page"]', - ); - expect(current).not.toBeNull(); - // Visual text may be truncated but aria-current must be present - expect(current?.getAttribute("aria-current")).toBe("page"); - }); - - it("applies middle-ellipsis truncation to long middle crumb labels", () => { - const { container } = renderPage(); - - // The two middle crumbs are rendered inside `.breadcrumb-middle` list items - // as visible links on desktop, and also inside the popover on mobile. - // On desktop they are in .breadcrumb-middle items. - const middleItems = Array.from( - container.querySelectorAll( - ".breadcrumb-middle .breadcrumb-link", - ), - ); - - // Both middle labels are long and should be truncated - expect(middleItems.length).toBe(2); - for (const item of middleItems) { - expect(item.textContent).toContain("\u2026"); - } + it("omits Burst and Concurrent columns when no tier has them", async () => { + renderPage("no-burst"); + const table = await screen.findByRole("table", { name: /No Burst API/ }); + expect(within(table).queryByText("Burst limit")).toBeNull(); + expect(within(table).queryByText("Concurrent connections")).toBeNull(); + expect(within(table).getByText("Requests / min")).toBeTruthy(); }); - it("preserves full labels as accessible names on truncated crumbs", () => { - const { container } = renderPage(); - - const middleLinks = Array.from( - container.querySelectorAll( - ".breadcrumb-middle .breadcrumb-link", - ), - ); - - const fullLabels = [ - "GrantFox Wave Compute API – Stellar Edition", - "Rate Limits & Throttling Policies", - ]; - - middleLinks.forEach((link, i) => { - // title is always set - expect(link.getAttribute("title")).toBe(fullLabels[i]); - // aria-label is set when truncated (both labels exceed 28 chars) - expect(link.getAttribute("aria-label")).toBe(fullLabels[i]); - }); + it("shows the not-published state when the API has no rateLimits", async () => { + renderPage("unpublished"); + expect(await screen.findByText("This provider has not published rate limits.")).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); }); - // ── Rate-limit table ────────────────────────────────────────────────────── - - it("renders the rate-limit tier table", () => { - renderPage(); - expect(screen.getByRole("table", { name: /rate limit tiers/i })).toBeTruthy(); + it("shows a not-found panel for an unknown id", async () => { + renderPage("nope"); + expect(await screen.findByTestId("rate-limit-not-found")).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); }); - it("shows all four plan tiers", () => { - renderPage(); - for (const plan of ["Free", "Developer", "Pro", "Enterprise"]) { - expect(screen.getByText(plan)).toBeTruthy(); + it("builds a breadcrumb from the API name whose hrefs all resolve to existing routes", async () => { + renderPage("three-tier"); + await screen.findByRole("table"); + const nav = screen.getByRole("navigation", { name: /breadcrumb/i }); + const hrefs = Array.from(nav.querySelectorAll("a")).map((a) => a.getAttribute("href") ?? ""); + expect(hrefs.length).toBeGreaterThanOrEqual(3); + for (const href of hrefs) { + const resolves = + href === "/marketplace" || href.startsWith("/details/") || isMarketplaceSubpage(href); + expect(resolves, href).toBe(true); } + expect(nav.textContent).toContain("Three Tier API"); }); - it("shows the correct requests-per-minute value for the Free tier", () => { - renderPage(); - // "10" appears in both the Free row (req/min) and Developer row (concurrent), - // so we confirm it is present at least once in the table. - const cells = screen.getAllByText("10"); - expect(cells.length).toBeGreaterThanOrEqual(1); + it("contains no GrantFox literals", async () => { + const { container } = renderPage("three-tier"); + await screen.findByRole("table"); + expect(container.textContent).not.toMatch(/GrantFox/i); }); +}); - it("renders the throttle note below the table", () => { - renderPage(); - expect( - screen.getByText(/rolling 60-second window/i), - ).toBeTruthy(); - }); - - // ── Color-blind safe patterns ───────────────────────────────────────────── - - it.each(["Free", "Developer", "Pro", "Enterprise"])( - `%s badge has a distinct pattern class for color-blind safety`, - (plan) => { - renderPage(); - const badge = screen.getByText(plan); - const expectedClass = PLAN_PATTERN_CLASSES[plan]; - expect(badge.classList.contains("rate-limit-badge")).toBe(true); - expect(badge.classList.contains(expectedClass)).toBe(true); - }, - ); - - it("each plan badge carries a data-pattern attribute describing the texture", () => { - renderPage(); - for (const plan of ["Free", "Developer", "Pro", "Enterprise"]) { - const badge = screen.getByText(plan); - expect(badge.getAttribute("data-pattern")).toBeTruthy(); - } +describe("ROUTE_CATALOGUE", () => { + it("has no dead /rate-limit entry", () => { + expect(ROUTE_CATALOGUE.some((r) => r.path === "/rate-limit")).toBe(false); }); }); diff --git a/src/pages/RateLimitCard.tsx b/src/pages/RateLimitCard.tsx index 8a11e4dc..fe33a5c5 100644 --- a/src/pages/RateLimitCard.tsx +++ b/src/pages/RateLimitCard.tsx @@ -1,92 +1,30 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; import Breadcrumb from "../components/Breadcrumb"; import useDocumentTitle from "../hooks/useDocumentTitle"; +import { fetchCatalog } from "../api/catalogApi"; +import type { APIItem } from "../data/mockApis"; /** - * RateLimitCard – GrantFox FWC26 campaign "Stellar Wave" (issue #567) + * RateLimitCard – per-API rate limits at `/marketplace/:apiId/rate-limits`. * - * Displays rate-limit configuration for a deeply nested API endpoint. - * The breadcrumb path intentionally contains long segment names to exercise - * the middle-ellipsis truncation feature (`maxLabelLength` prop) added to - * the Breadcrumb component. - * - * The breadcrumb trail is: - * Marketplace → GrantFox Wave Compute API (long) → Rate Limits → Current Page - * - * With `maxLabelLength={28}` the two middle segments are each capped at - * 28 characters in the visible label while the full strings remain available - * via `title` / `aria-label` for screen readers and on hover. + * The API is resolved from the catalogue (`fetchCatalog`) and the table is + * built from `api.rateLimits`. Optional columns (burst, concurrent + * connections) render only when at least one tier publishes that field. */ -/** Rate-limit tier shape used in the table below. */ -type RateLimitTier = { - plan: string; - requestsPerMinute: number; - burstLimit: number; - concurrentConnections: number; -}; +type RateLimitTier = NonNullable[number]; -const RATE_LIMIT_TIERS: RateLimitTier[] = [ - { - plan: "Free", - requestsPerMinute: 10, - burstLimit: 20, - concurrentConnections: 2, - }, - { - plan: "Developer", - requestsPerMinute: 120, - burstLimit: 200, - concurrentConnections: 10, - }, - { - plan: "Pro", - requestsPerMinute: 600, - burstLimit: 1000, - concurrentConnections: 50, - }, - { - plan: "Enterprise", - requestsPerMinute: 6000, - burstLimit: 10000, - concurrentConnections: 500, - }, -]; - -/** - * Breadcrumb path for this page. Segment labels are deliberately long so the - * middle-ellipsis truncation is visible without special configuration. - * - * Full path (un-truncated): - * Marketplace - * → GrantFox Wave Compute API – Stellar Edition - * → Rate Limits & Throttling Policies - * → Current Plan Configuration (current page) - */ -const BREADCRUMB_ITEMS = [ - { label: "Marketplace", href: "/marketplace" }, - { - label: "GrantFox Wave Compute API – Stellar Edition", - href: "/marketplace/grantfox-wave-compute", - }, - { - label: "Rate Limits & Throttling Policies", - href: "/marketplace/grantfox-wave-compute/rate-limits", - }, - { - label: "Current Plan Configuration", - href: "/marketplace/grantfox-wave-compute/rate-limits/config", - isCurrent: true, - }, -] as const; +type LoadState = + | { status: "loading" } + | { status: "ready"; api: APIItem } + | { status: "not-found" } + | { status: "error" }; -/** - * Maximum characters before a crumb label is shortened with a middle-ellipsis. - * Chosen so the two long middle segments fit comfortably in a single breadcrumb - * row even on narrow viewports. - */ +/** Maximum characters before a crumb label is shortened with a middle-ellipsis. */ const BREADCRUMB_MAX_LABEL = 28; -/** Pattern class and description for each plan tier (color-blind safe). */ +/** Pattern class and description for known plan tiers (color-blind safe). */ const PLAN_PATTERNS: Record = { Free: { class: "free", description: "solid baseline" }, Developer: { class: "developer", description: "dot pattern" }, @@ -94,12 +32,59 @@ const PLAN_PATTERNS: Record = { Enterprise: { class: "enterprise", description: "crosshatch pattern" }, }; +const DEFAULT_PATTERN = { class: "custom", description: "outlined" }; + export default function RateLimitCard() { + const { apiId } = useParams<{ apiId: string }>(); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + const controller = new AbortController(); + setState({ status: "loading" }); + fetchCatalog(controller.signal) + .then((items) => { + if (controller.signal.aborted) return; + const api = items.find((item) => item.id === apiId); + setState(api ? { status: "ready", api } : { status: "not-found" }); + }) + .catch(() => { + if (controller.signal.aborted) return; + setState({ status: "error" }); + }); + return () => controller.abort(); + }, [apiId]); + + const api = state.status === "ready" ? state.api : null; + useDocumentTitle( - "Rate Limit Configuration – Callora", - "Current rate-limit plan and throttling policies for the GrantFox Wave Compute API.", + api ? `${api.name} Rate Limits – Callora` : "Rate Limits – Callora", + api + ? `Published rate limits and throttling policies for ${api.name}.` + : "Published rate limits and throttling policies for an API.", ); + const numberFormat = new Intl.NumberFormat(); + const tiers: RateLimitTier[] = api?.rateLimits ?? []; + const showBurst = tiers.some((t) => t.burstLimit !== undefined); + const showConcurrent = tiers.some((t) => t.concurrentConnections !== undefined); + const formatValue = (value?: number) => + value === undefined ? "—" : numberFormat.format(value); + + const breadcrumbItems = api + ? [ + { label: "Marketplace", href: "/marketplace" }, + { label: api.name, href: `/details/${encodeURIComponent(api.id)}` }, + { + label: "Rate Limits", + href: `/marketplace/${encodeURIComponent(api.id)}/rate-limits`, + isCurrent: true, + }, + ] + : [ + { label: "Marketplace", href: "/marketplace" }, + { label: "Rate Limits", href: "#", isCurrent: true }, + ]; + return (
- {/* - Breadcrumb uses maxLabelLength={28} so each crumb label is capped at - 28 characters. Both long middle-segment names will be shown with a - "…" in the middle, preserving the start and end of the real label. - The full strings are always in `title` / `aria-label`. - */} - +

Rate Limit Configuration

-

- GrantFox Wave Compute API – Stellar Edition  ·  FWC26 -

+ {api &&

{api.name}

}
- - - - - - - - - - - {RATE_LIMIT_TIERS.map((tier) => ( - - - - - - - ))} - -
PlanRequests / minBurst limitConcurrent connections
- - {tier.plan} - - {tier.requestsPerMinute.toLocaleString()}{tier.burstLimit.toLocaleString()}{tier.concurrentConnections.toLocaleString()}
- -

- Limits reset on a rolling 60-second window. Burst allowance is - consumed first; sustained throughput is governed by the per-minute - quota. Enterprise plans can request custom limits via the support - portal. -

+ {state.status === "loading" && ( +

+ Loading rate limits… +

+ )} + + {state.status === "error" && ( +
+ Could not load rate limits. Please try again later. +
+ )} + + {state.status === "not-found" && ( +
+ API not found. We couldn't find an API with the id “{apiId}”.{" "} + Browse the Marketplace +
+ )} + + {api && tiers.length === 0 && ( +

+ This provider has not published rate limits. +

+ )} + + {api && tiers.length > 0 && ( + <> + + + + + + + {showBurst && } + {showConcurrent && } + + + + {tiers.map((tier) => { + const pattern = PLAN_PATTERNS[tier.plan] ?? DEFAULT_PATTERN; + return ( + + + + {showBurst && } + {showConcurrent && } + + ); + })} + +
Rate limit tiers for {api.name}
PlanRequests / minBurst limitConcurrent connections
+ + {tier.plan} + + {formatValue(tier.requestsPerMinute)}{formatValue(tier.burstLimit)}{formatValue(tier.concurrentConnections)}
+ +

+ Limits are published by the API provider. Contact the provider + for custom limits. +

+ + )}
); diff --git a/src/renderRoute.tsx b/src/renderRoute.tsx index 799be423..f1527071 100644 --- a/src/renderRoute.tsx +++ b/src/renderRoute.tsx @@ -22,6 +22,7 @@ import MarketplacePageSkeleton from "./pages/MarketplacePage.skeleton"; import ApiDetailPageSkeleton from "./pages/ApiDetailPage.skeleton"; import LatencyChart from "./pages/LatencyChart"; import StandaloneShell from "./components/StandaloneShell"; +import { isMarketplaceSubpage } from "./routeMatchers"; /** * The slice of a React 18 root that routing needs. Keeping it structural lets @@ -66,7 +67,7 @@ export async function renderRoute( return; } - if (pathname.startsWith("/marketplace") && !pathname.endsWith("/sla")) { + if (pathname.startsWith("/marketplace") && !isMarketplaceSubpage(pathname)) { startRouteLoading(); root.render(wrap()); const mod = await import("./pages/MarketplacePage"); diff --git a/src/routeMatchers.test.ts b/src/routeMatchers.test.ts new file mode 100644 index 00000000..28c4fe76 --- /dev/null +++ b/src/routeMatchers.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { isMarketplaceSubpage } from "./routeMatchers"; + +describe("isMarketplaceSubpage", () => { + it.each([ + ["/marketplace", false], + ["/marketplace?x", false], + ["/marketplace/a/sla", true], + ["/marketplace/a/rate-limits", true], + ["/marketplace/a/rate-limits/", true], + ["/marketplace/a/other", false], + ["/marketplace/a/rate-limits/config", false], + ])("%s -> %s", (path, expected) => { + expect(isMarketplaceSubpage(path)).toBe(expected); + }); +}); diff --git a/src/routeMatchers.ts b/src/routeMatchers.ts new file mode 100644 index 00000000..48e2e80c --- /dev/null +++ b/src/routeMatchers.ts @@ -0,0 +1,16 @@ +/** + * Pure routing predicates shared by the custom router (`renderRoute.tsx`). + */ + +const MARKETPLACE_SUBPAGE = /^\/marketplace\/[^/]+\/(sla|rate-limits)\/?$/; + +/** + * True when `pathname` is a per-API marketplace subpage that is rendered by + * `App` (e.g. `/marketplace/:apiId/sla`, `/marketplace/:apiId/rate-limits`) + * rather than by the Marketplace listing page. Query strings and hashes are + * ignored. + */ +export function isMarketplaceSubpage(pathname: string): boolean { + const path = pathname.split(/[?#]/)[0]; + return MARKETPLACE_SUBPAGE.test(path); +}