Skip to content

Commit 8d4f757

Browse files
authored
feat: add productSlug API param and scope CORS for widget consumers (#91)
* feat: add productSlug API param and scope CORS for widget consumers Add productSlug query parameter to the public changelogs API, enabling filtering by product slug for embeddable widget consumers. Scope wildcard CORS to only widget-consumed routes while restricting remaining public API routes to the app origin. - Add productSlug to ChangelogQueryParamsSchema and controller - Add Prisma nested relation filter for productSlug in findPublished - Scope origin:'*' CORS to /public/api/changelogs and /products only - Restrict remaining public API routes to getCorsOrigins() - Fix Swagger docs: rename pageSize to limit, add productSlug param - Add e2e tests for productSlug filtering Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix: correct limit default in Swagger docs from 10 to 20 Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix: forward productSlug in Angular changelog service buildParams Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent dc65d1c commit 8d4f757

File tree

7 files changed

+36
-4
lines changed

7 files changed

+36
-4
lines changed

apps/lfx-changelog/e2e/specs/api/public-changelogs.api.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,26 @@ test.describe('Public Changelogs API', () => {
9292
expect(page2Body.totalPages).toBe(Math.ceil(PUBLISHED_COUNT / 2));
9393
});
9494

95+
test('should support productSlug filtering', async () => {
96+
const res = await api.get('/public/api/changelogs?productSlug=e2e-easycla');
97+
const body = await res.json();
98+
99+
expect(body.success).toBe(true);
100+
expect(body.data.length).toBeGreaterThan(0);
101+
for (const entry of body.data) {
102+
expect(entry.product.slug).toBe('e2e-easycla');
103+
}
104+
});
105+
106+
test('should return empty data for non-existent productSlug', async () => {
107+
const res = await api.get('/public/api/changelogs?productSlug=does-not-exist');
108+
const body = await res.json();
109+
110+
expect(body.success).toBe(true);
111+
expect(body.data).toHaveLength(0);
112+
expect(body.total).toBe(0);
113+
});
114+
95115
test('should support productId filtering', async () => {
96116
// First, get products to find a valid ID
97117
const productsRes = await api.get('/public/api/products');

apps/lfx-changelog/src/app/shared/services/changelog.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export class ChangelogService {
7777
private buildParams(params?: ChangelogQueryParams): HttpParams {
7878
let httpParams = new HttpParams();
7979
if (params?.productId) httpParams = httpParams.set('productId', params.productId);
80+
if (params?.productSlug) httpParams = httpParams.set('productSlug', params.productSlug);
8081
if (params?.status) httpParams = httpParams.set('status', params.status);
8182
if (params?.page) httpParams = httpParams.set('page', params.page.toString());
8283
if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString());

apps/lfx-changelog/src/server/controllers/changelog.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class ChangelogController {
2020
try {
2121
const result = await this.changelogService.findPublished({
2222
productId: req.query['productId'] as string | undefined,
23+
productSlug: req.query['productSlug'] as string | undefined,
2324
page: req.query['page'] ? parseInt(req.query['page'] as string, 10) : undefined,
2425
limit: req.query['limit'] ? parseInt(req.query['limit'] as string, 10) : undefined,
2526
});

apps/lfx-changelog/src/server/services/changelog.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class ChangelogService {
2424

2525
const where: Prisma.ChangelogEntryWhereInput = { status: 'published', product: { isActive: true } };
2626
if (params.productId) where.productId = params.productId;
27+
if (params.productSlug) where.product = { isActive: true, slug: params.productSlug };
2728
if (params.query) {
2829
where.OR = [{ title: { contains: params.query, mode: 'insensitive' } }, { description: { contains: params.query, mode: 'insensitive' } }];
2930
}

apps/lfx-changelog/src/server/setup/cors.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@ import type { Express, NextFunction, Request, Response } from 'express';
99

1010
/**
1111
* Registers all CORS strategies:
12-
* - `/public/api` — allowed origins for external consumers (excluding chat)
12+
* - `/public/api/changelogs`, `/public/api/products` — open to all origins (widget consumers)
13+
* - `/public/api/*` (remaining) — restricted to app origin
1314
* - `/mcp` — open to all origins (AI clients)
1415
* - `/api` — conditional CORS for API-key requests (excluding chat)
1516
*/
1617
export function setupCors(app: Express): void {
17-
// Public API — external consumers; chat routes are same-origin only
18+
// Widget-consumed routes — open CORS for embeddable widget on any origin
19+
const widgetCors = cors({ origin: '*', methods: ['GET', 'HEAD', 'OPTIONS'], maxAge: 86400 });
20+
app.use('/public/api/changelogs', widgetCors);
21+
app.use('/public/api/products', widgetCors);
22+
23+
// Remaining public API routes (search, blogs, chat, etc.) — restricted to app origin
1824
app.use('/public/api', (req: Request, res: Response, next: NextFunction) => {
19-
if (req.path.startsWith('/chat')) {
25+
// Skip paths already handled by widgetCors above to avoid double-applying
26+
if (req.path.startsWith('/changelogs') || req.path.startsWith('/products')) {
2027
next();
2128
return;
2229
}

apps/lfx-changelog/src/server/swagger/paths/public-changelogs.path.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ publicChangelogRegistry.registerPath({
1717
request: {
1818
query: z.object({
1919
page: z.coerce.number().optional().openapi({ description: 'Page number (default: 1)' }),
20-
pageSize: z.coerce.number().optional().openapi({ description: 'Items per page (default: 10)' }),
20+
limit: z.coerce.number().optional().openapi({ description: 'Items per page (default: 20)' }),
2121
productId: z.string().optional().openapi({ description: 'Filter by product ID' }),
22+
productSlug: z.string().optional().openapi({ description: 'Filter by product slug (e.g., "insights", "easycla")' }),
2223
}),
2324
},
2425
responses: {

packages/shared/src/schemas/search.schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod';
77
export const ChangelogQueryParamsSchema = z
88
.object({
99
productId: z.string().uuid().optional().openapi({ description: 'Filter by product ID' }),
10+
productSlug: z.string().optional().openapi({ description: 'Filter by product slug (e.g., "insights", "easycla")' }),
1011
status: z.string().optional().openapi({ description: 'Filter by status (draft, published)' }),
1112
query: z.string().optional().openapi({ description: 'Text search keywords to filter by title or description' }),
1213
page: z.coerce.number().int().min(1).optional().openapi({ description: 'Page number' }),

0 commit comments

Comments
 (0)