Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ A product is addressed as `{account_id}/{product_id}`, for example `kerner-lab/f

- Product page: `https://source.coop/{account_id}/{product_id}`
- Product metadata as JSON: `https://source.coop/api/v1/products/{account_id}/{product_id}`
- An account's public products as JSON: `https://source.coop/api/v1/products/{account_id}`
- An account's public products as JSON: `https://source.coop/api/v1/products/{account_id}`, a page at a time as `{"items": [...], "next_cursor": ...}`; pass `next_cursor` back as `?cursor=` until it's null, and `?limit=` up to 100
- Search all public products: `https://source.coop/api/v1/products?q={text}&tags={tag,tag}`, paged the same way
- Files, over HTTP: `https://data.source.coop/{account_id}/{product_id}/{path}`
- Files, over S3: bucket `{account_id}`, key prefix `{product_id}/`, endpoint `https://data.source.coop`; public products need no credentials (`aws s3 ls s3://{account_id}/{product_id}/ --endpoint-url https://data.source.coop --no-sign-request`)

Expand Down
5 changes: 2 additions & 3 deletions src/app/(app)/products/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
jest.mock("@/components/layout", () => ({}));
jest.mock("@/components/features/products/ProductsList", () => ({}));
jest.mock("@/components/features/products/ProductsFilters", () => ({}));
jest.mock("@/lib/actions/products", () => ({
getPaginatedProducts: jest.fn(),
}));
jest.mock("@/lib", () => ({}));
jest.mock("@/lib/operations/products", () => ({}));

import { metadata } from "./page";

Expand Down
30 changes: 20 additions & 10 deletions src/app/(app)/products/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { PageHeader } from "@/components/layout";
import { ProductsList } from "@/components/features/products/ProductsList";
import { ProductsFilters } from "@/components/features/products/ProductsFilters";
import { getPaginatedProducts } from "@/lib/actions/products";
import { getPageSession } from "@/lib";
import { listProducts } from "@/lib/operations/products";
import { notFound } from "next/navigation";
import { Badge, Box, Flex, Text } from "@radix-ui/themes";

export const metadata = {
Expand Down Expand Up @@ -35,11 +37,19 @@ export default async function ProductsPage({
const { search, tags, cursor, previous, featured } = await searchParams;

const featuredOnly = featured === "1";
const filters = search || tags || featuredOnly
? { search, tags, featuredOnly: featuredOnly || undefined }
: undefined;
const { products, hasNextPage, hasPreviousPage, nextCursor, previousCursor } =
await getPaginatedProducts(100, cursor, previous, undefined, filters);
const session = await getPageSession();
const query = {
q: search,
tags,
featured: featuredOnly ? "true" : undefined,
limit: 100,
};
// A cursor that's no longer one (a stale bookmark) starts over at page one.
let result = await listProducts(session, { ...query, cursor });
if (!result.ok && cursor) result = await listProducts(session, query);
// Only a hand-edited query, such as a repeated ?tags=, gets here.
if (!result.ok) notFound();
const { items: products, next_cursor } = result.value;

const hasActiveFilters = search || tags || featuredOnly;

Expand Down Expand Up @@ -75,10 +85,10 @@ export default async function ProductsPage({
<ProductsList
products={products}
pagination={{
hasNextPage,
hasPreviousPage,
nextCursor,
previousCursor,
hasNextPage: !!next_cursor,
hasPreviousPage: !!cursor,
nextCursor: next_cursor ?? undefined,
previousCursor: previous,
currentCursor: cursor,
}}
/>
Expand Down
9 changes: 2 additions & 7 deletions src/app/api/openapi/route-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,23 @@ jest.mock("@/lib/clients/database", () => ({}));
const UNREGISTERED = new Set([
"DELETE /accounts/{account_id}",
"DELETE /data-connections/{data_connection_id}",
"DELETE /products/{account_id}/{repository_id}",
"GET /accounts/{account_id}",
"GET /accounts/{account_id}/flags",
"GET /accounts/{account_id}/profile",
"GET /data-connections",
"GET /data-connections/{data_connection_id}",
"GET /products/{account_id}",
"GET /products/{account_id}/{repository_id}",
"GET /products/{account_id}/{repository_id}/permissions",
"GET /products/{account_id}/{product_id}/permissions",
"GET /products/featured",
"GET /whoami",
"POST /accounts/{account_id}/trusts/exchanges",
"POST /data-connections",
"POST /products/{account_id}",
"POST /secret-scanning/github",
"POST /service-account-keys/exchanges",
"POST /service-account-keys/revocations",
"PUT /accounts/{account_id}/flags",
"PUT /accounts/{account_id}/profile",
"PUT /data-connections/{data_connection_id}",
"PUT /products/{account_id}/{repository_id}",
"PUT /products/{account_id}/{repository_id}/featured",
"PUT /products/{account_id}/{product_id}/featured",
]);

const V1 = path.join(__dirname, "../v1");
Expand Down
5 changes: 4 additions & 1 deletion src/app/api/openapi/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import "../v1/memberships/[membership_id]/route";
import "../v1/memberships/[membership_id]/accept/route";
import "../v1/memberships/[membership_id]/reject/route";
import "../v1/memberships/[membership_id]/revoke/route";
import "../v1/products/[account_id]/[repository_id]/members/route";
import "../v1/products/route";
import "../v1/products/[account_id]/route";
import "../v1/products/[account_id]/[product_id]/route";
import "../v1/products/[account_id]/[product_id]/members/route";

// The registry is complete once the imports above have run, so the document
// is built once per instance rather than per request.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { StatusCodes } from "http-status-codes";

/**
* @openapi
* /products/{account_id}/{repository_id}/featured:
* /products/{account_id}/{product_id}/featured:
* put:
* tags: [Products]
* summary: Updates a products featured state
Expand All @@ -21,7 +21,7 @@ import { StatusCodes } from "http-status-codes";
* type: string
* description: The ID of the account that owns the repository
* - in: path
* name: repository_id
* name: product_id
* required: true
* schema:
* type: string
Expand All @@ -46,18 +46,18 @@ import { StatusCodes } from "http-status-codes";
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ account_id: string; repository_id: string }> }
{ params }: { params: Promise<{ account_id: string; product_id: string }> }
) {
try {
const session = await getApiSession(request);
const { account_id, repository_id } = await params;
const { account_id, product_id } = await params;

const repository = await productsTable.fetchById(account_id, repository_id);
const repository = await productsTable.fetchById(account_id, product_id);

if (!repository) {
return NextResponse.json(
{
error: `Repository with ID ${account_id}/${repository_id} not found`,
error: `Repository with ID ${account_id}/${product_id} not found`,
},
{ status: StatusCodes.NOT_FOUND }
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import { withApiSession, toResponse } from "@/lib/api/handler";
import { bearer, errors, json, registry } from "@/lib/api/openapi";
import { inviteMember, listMembers } from "@/lib/operations/memberships";

type Params = { account_id: string; repository_id: string };
type Params = { account_id: string; product_id: string };

const params = z.object({
account_id: z.string().openapi({ description: "The product owner's ID." }),
repository_id: z.string().openapi({ description: "The product's ID." }),
product_id: z.string().openapi({ description: "The product's ID." }),
});

registry.registerPath({
method: "get",
path: "/products/{account_id}/{repository_id}/members",
path: "/products/{account_id}/{product_id}/members",
tags: ["Memberships"],
summary: "List a product's members",
description:
Expand All @@ -31,14 +31,14 @@ export const GET = withApiSession<Params>(async ({ session, params }) =>
toResponse(
await listMembers(session, {
account_id: params.account_id,
product_id: params.repository_id,
product_id: params.product_id,
})
)
);

registry.registerPath({
method: "post",
path: "/products/{account_id}/{repository_id}/members",
path: "/products/{account_id}/{product_id}/members",
tags: ["Memberships"],
summary: "Invite a member to a product",
description:
Expand All @@ -59,7 +59,7 @@ export const POST = withApiSession<Params>(async ({ session, params, body }) =>
await inviteMember(session, {
...(body as object),
membership_account_id: params.account_id,
repository_id: params.repository_id,
repository_id: params.product_id,
}),
StatusCodes.CREATED
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { LOGGER } from "@/lib";

/**
* @openapi
* /products/{account_id}/{repository_id}/permissions:
* /products/{account_id}/{product_id}/permissions:
* get:
* tags: [Products]
* summary: Get repository permissions
Expand All @@ -25,7 +25,7 @@ import { LOGGER } from "@/lib";
* type: string
* description: The ID of the account that owns the repository
* - in: path
* name: repository_id
* name: product_id
* required: true
* schema:
* type: string
Expand All @@ -48,18 +48,18 @@ import { LOGGER } from "@/lib";
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ account_id: string; repository_id: string }> }
{ params }: { params: Promise<{ account_id: string; product_id: string }> }
) {
try {
const session = await getApiSession(request);
const { account_id, repository_id } = await params;
const { account_id, product_id } = await params;

const product = await productsTable.fetchById(account_id, repository_id);
const product = await productsTable.fetchById(account_id, product_id);

if (!product) {
return NextResponse.json(
{
error: `Repository with ID ${account_id}/${repository_id} not found`,
error: `Repository with ID ${account_id}/${product_id} not found`,
},
{ status: StatusCodes.NOT_FOUND }
);
Expand All @@ -76,7 +76,7 @@ export async function GET(
context: "permissions check",
metadata: {
account_id,
repository_id,
product_id,
hasSession: !!session,
hasProduct: !!product,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
/**
* @jest-environment node
*
* Tests for the products GET endpoint (/api/v1/products/[account_id]/[repository_id])
* Tests for the products GET endpoint (/api/v1/products/[account_id]/[product_id])
*/

// Mock the logger BEFORE importing anything else to prevent errors during import
// jest.mock("@/lib/logging", () => ({
// LOGGER: {
// error: jest.fn(),
// warn: jest.fn(),
// info: jest.fn(),
// debug: jest.fn(),
// },
// }));

import { NextRequest } from "next/server";
import { productsTable } from "@/lib/clients/database/products";
import {
Expand All @@ -32,6 +22,8 @@ jest.mock("@/lib/clients/database/products", () => ({
}));

jest.mock("@/lib/clients/database", () => ({
// The product table the route's operation reads, under its other import path.
...jest.requireMock("@/lib/clients/database/products"),
accountsTable: {
fetchById: jest.fn(),
},
Expand All @@ -56,10 +48,10 @@ jest.mock("@/lib/api/oidc", () => ({

const { GET } = require("./route");

describe("/api/v1/products/[account_id]/[repository_id]", () => {
describe("/api/v1/products/[account_id]/[product_id]", () => {
const mockRepository = {
account_id: "test-account",
repository_id: "test-repo",
product_id: "test-repo",
name: "Test Repository",
description: "A test repository",
created_at: "2024-01-01T00:00:00Z",
Expand All @@ -82,7 +74,7 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
(authenticateWithOidcToken as jest.Mock).mockResolvedValue(null);
});

describe("GET /api/v1/products/[account_id]/[repository_id]", () => {
describe("GET /api/v1/products/[account_id]/[product_id]", () => {
test("returns 401 when not authenticated", async () => {
(productsTable.fetchById as jest.Mock).mockResolvedValue(mockRepository);

Expand All @@ -93,13 +85,13 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
const response = await GET(request, {
params: Promise.resolve({
account_id: "test-account",
repository_id: "test-repo",
product_id: "test-repo",
}),
});

expect(response.status).toBe(401);
const responseData = await response.json();
expect(responseData).toEqual({ error: "Unauthorized" });
expect(responseData).toMatchObject({ error: { code: "unauthenticated" } });
});

test("returns 404 when repository is not found", async () => {
Expand All @@ -111,15 +103,13 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
const response = await GET(request, {
params: Promise.resolve({
account_id: "test-account",
repository_id: "nonexistent-repo",
product_id: "nonexistent-repo",
}),
});

expect(response.status).toBe(404);
const responseData = await response.json();
expect(responseData).toEqual({
error: "Repository with ID test-account/nonexistent-repo not found",
});
expect(responseData).toMatchObject({ error: { code: "not_found" } });
});

test("returns 404 (not 401) for a deactivated product when not permitted", async () => {
Expand All @@ -135,15 +125,13 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
const response = await GET(request, {
params: Promise.resolve({
account_id: "test-account",
repository_id: "test-repo",
product_id: "test-repo",
}),
});

expect(response.status).toBe(404);
const responseData = await response.json();
expect(responseData).toEqual({
error: "Repository with ID test-account/test-repo not found",
});
expect(responseData).toMatchObject({ error: { code: "not_found" } });
});

});
Expand All @@ -170,7 +158,7 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
const orgOwnedProduct = {
...mockRepository,
account_id: "test-org",
repository_id: "private-repo",
product_id: "private-repo",
};
(productsTable.fetchById as jest.Mock).mockResolvedValue(orgOwnedProduct);

Expand All @@ -181,7 +169,7 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
const response = await GET(request, {
params: Promise.resolve({
account_id: "test-org",
repository_id: "private-repo",
product_id: "private-repo",
}),
});

Expand All @@ -190,7 +178,7 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
expect(responseData).toEqual(orgOwnedProduct);
});

test("org account cannot access another account's private product", async () => {
test("org account may not read another account's private product", async () => {
// Product is owned by a different account
(productsTable.fetchById as jest.Mock).mockResolvedValue(mockRepository);

Expand All @@ -201,11 +189,11 @@ describe("/api/v1/products/[account_id]/[repository_id]", () => {
const response = await GET(request, {
params: Promise.resolve({
account_id: "test-account",
repository_id: "test-repo",
product_id: "test-repo",
}),
});

expect(response.status).toBe(401);
expect(response.status).toBe(403);
});
});
});
Loading
Loading