Skip to content

Commit 2d47df5

Browse files
authored
Merge pull request #359 from commonknowledge/copilot/fetch-all-data-sources
Add authenticated REST endpoint for listing user-readable data sources across organisations
2 parents 846d0dd + f1b2995 commit 2d47df5

4 files changed

Lines changed: 380 additions & 0 deletions

File tree

src/api/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,27 @@ console.log(`Found ${nearby.features.length} locations within 5km`);
596596

597597
## API Endpoint Reference
598598

599+
**Endpoint:** `GET /api/rest/data-sources`
600+
601+
**Authentication:** Basic Auth (email:password)
602+
603+
**Response:** `APIDataSourceListResponse`
604+
605+
Returns all data sources the authenticated user can access across their organisations:
606+
607+
```ts
608+
[
609+
{
610+
id: "data-source-id",
611+
name: "Data Source Name",
612+
organisation: {
613+
id: "organisation-id",
614+
name: "Organisation Name",
615+
},
616+
},
617+
];
618+
```
619+
599620
**Endpoint:** `GET /api/rest/data-sources/{dataSourceId}/geojson`
600621

601622
**Authentication:** Basic Auth (email:password)

src/api/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ export interface APIGeocodeResult {
3939
samplePoint: APIPoint | null;
4040
}
4141

42+
// ============================================================================
43+
// DATA SOURCES API TYPES - GET /api/rest/data-sources
44+
// ============================================================================
45+
46+
/**
47+
* Organisation information for a data source
48+
*/
49+
export interface APIDataSourceOrganisation {
50+
id: string;
51+
name: string;
52+
}
53+
54+
/**
55+
* A readable data source available to the authenticated user
56+
*/
57+
export interface APIDataSourceListItem {
58+
id: string;
59+
name: string;
60+
organisation: APIDataSourceOrganisation;
61+
}
62+
63+
/**
64+
* Response from GET /api/rest/data-sources
65+
*/
66+
export type APIDataSourceListResponse = APIDataSourceListItem[];
67+
4268
// ============================================================================
4369
// GEOJSON API TYPES - GET /api/rest/data-sources/:dataSourceId/geojson
4470
// ============================================================================
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { NextResponse } from "next/server";
2+
import { findOrganisationsByUserId } from "@/server/repositories/Organisation";
3+
import { findUserByEmailAndPassword } from "@/server/repositories/User";
4+
import { db } from "@/server/services/database";
5+
import type { NextRequest } from "next/server";
6+
7+
/**
8+
* Authenticated REST API for listing readable data sources for a user.
9+
*
10+
* GET /api/rest/data-sources
11+
*
12+
* Authentication: Basic Auth (email:password)
13+
* Returns: List of readable data sources across all organisations the user belongs to.
14+
*/
15+
export async function GET(request: NextRequest) {
16+
const authHeader = request.headers.get("authorization");
17+
const basicMatch = authHeader?.match(/^Basic\s+(.+)$/i);
18+
if (!basicMatch) {
19+
return new NextResponse(
20+
JSON.stringify({ error: "Missing or invalid Authorization header" }),
21+
{
22+
status: 401,
23+
headers: {
24+
"WWW-Authenticate": 'Basic realm="Data Source API"',
25+
"Content-Type": "application/json",
26+
},
27+
},
28+
);
29+
}
30+
31+
const base64Credentials = basicMatch[1]?.trim();
32+
if (!base64Credentials) {
33+
return new NextResponse(
34+
JSON.stringify({ error: "Missing or invalid Authorization header" }),
35+
{
36+
status: 401,
37+
headers: {
38+
"WWW-Authenticate": 'Basic realm="Data Source API"',
39+
"Content-Type": "application/json",
40+
},
41+
},
42+
);
43+
}
44+
45+
const credentials = Buffer.from(base64Credentials, "base64").toString(
46+
"utf-8",
47+
);
48+
const separatorIndex = credentials.indexOf(":");
49+
const email =
50+
separatorIndex >= 0 ? credentials.slice(0, separatorIndex) : undefined;
51+
const password =
52+
separatorIndex >= 0 ? credentials.slice(separatorIndex + 1) : undefined;
53+
54+
if (!email || !password) {
55+
return new NextResponse(
56+
JSON.stringify({ error: "Invalid credentials format" }),
57+
{
58+
status: 401,
59+
headers: {
60+
"WWW-Authenticate": 'Basic realm="Data Source API"',
61+
"Content-Type": "application/json",
62+
},
63+
},
64+
);
65+
}
66+
67+
const user = await findUserByEmailAndPassword({ email, password });
68+
if (!user) {
69+
return new NextResponse(JSON.stringify({ error: "Invalid credentials" }), {
70+
status: 401,
71+
headers: {
72+
"WWW-Authenticate": 'Basic realm="Data Source API"',
73+
"Content-Type": "application/json",
74+
},
75+
});
76+
}
77+
78+
const organisations = await findOrganisationsByUserId(user.id);
79+
const organisationIds = organisations.map((organisation) => organisation.id);
80+
if (organisationIds.length === 0) {
81+
return NextResponse.json([]);
82+
}
83+
84+
const dataSources = await db
85+
.selectFrom("dataSource")
86+
.innerJoin("organisation", "organisation.id", "dataSource.organisationId")
87+
.where("dataSource.organisationId", "in", organisationIds)
88+
.select([
89+
"dataSource.id as id",
90+
"dataSource.name as name",
91+
"organisation.id as organisationId",
92+
"organisation.name as organisationName",
93+
])
94+
.orderBy("organisation.name", "asc")
95+
.orderBy("dataSource.name", "asc")
96+
.execute();
97+
98+
return NextResponse.json(
99+
dataSources.map((dataSource) => ({
100+
id: dataSource.id,
101+
name: dataSource.name,
102+
organisation: {
103+
id: dataSource.organisationId,
104+
name: dataSource.organisationName,
105+
},
106+
})),
107+
);
108+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { NextRequest } from "next/server";
2+
import { v4 as uuidv4 } from "uuid";
3+
import { beforeAll, describe, expect, it } from "vitest";
4+
import { GET } from "@/app/api/rest/data-sources/route";
5+
import {
6+
ColumnType,
7+
DataSourceRecordType,
8+
DataSourceType,
9+
GeocodingType,
10+
} from "@/server/models/DataSource";
11+
import { createDataSource } from "@/server/repositories/DataSource";
12+
import { upsertOrganisation } from "@/server/repositories/Organisation";
13+
import { upsertUser } from "@/server/repositories/User";
14+
import { db } from "@/server/services/database";
15+
16+
describe("Data sources REST API", () => {
17+
const testPassword = "testPassword123";
18+
const colonPassword = "test:password:123";
19+
let testUser: Awaited<ReturnType<typeof upsertUser>>;
20+
let testUserWithColonPassword: Awaited<ReturnType<typeof upsertUser>>;
21+
let organisationOne: Awaited<ReturnType<typeof upsertOrganisation>>;
22+
let organisationTwo: Awaited<ReturnType<typeof upsertOrganisation>>;
23+
let organisationThree: Awaited<ReturnType<typeof upsertOrganisation>>;
24+
let dataSourceOne: Awaited<ReturnType<typeof createDataSource>>;
25+
let dataSourceTwo: Awaited<ReturnType<typeof createDataSource>>;
26+
let dataSourceThree: Awaited<ReturnType<typeof createDataSource>>;
27+
28+
beforeAll(async () => {
29+
testUser = await upsertUser({
30+
email: `test-data-sources-${uuidv4()}@example.com`,
31+
password: testPassword,
32+
name: "Test User",
33+
avatarUrl: null,
34+
});
35+
testUserWithColonPassword = await upsertUser({
36+
email: `test-data-sources-colon-${uuidv4()}@example.com`,
37+
password: colonPassword,
38+
name: "Test User Colon Password",
39+
avatarUrl: null,
40+
});
41+
42+
organisationOne = await upsertOrganisation({
43+
name: `Test Organisation One ${uuidv4()}`,
44+
});
45+
organisationTwo = await upsertOrganisation({
46+
name: `Test Organisation Two ${uuidv4()}`,
47+
});
48+
organisationThree = await upsertOrganisation({
49+
name: `Test Organisation Three ${uuidv4()}`,
50+
});
51+
52+
await db
53+
.insertInto("organisationUser")
54+
.values([
55+
{ organisationId: organisationOne.id, userId: testUser.id },
56+
{ organisationId: organisationTwo.id, userId: testUser.id },
57+
{
58+
organisationId: organisationOne.id,
59+
userId: testUserWithColonPassword.id,
60+
},
61+
])
62+
.execute();
63+
64+
dataSourceOne = await createDataSource({
65+
name: `Org1 Data Source ${uuidv4()}`,
66+
recordType: DataSourceRecordType.Locations,
67+
autoEnrich: false,
68+
autoImport: false,
69+
public: false,
70+
config: { type: DataSourceType.CSV, url: "https://example.com/org1.csv" },
71+
columnDefs: [{ name: "name", type: ColumnType.String }],
72+
columnMetadata: [],
73+
columnRoles: { nameColumns: ["name"] },
74+
geocodingConfig: { type: GeocodingType.None },
75+
enrichments: [],
76+
organisationId: organisationOne.id,
77+
});
78+
79+
dataSourceTwo = await createDataSource({
80+
name: `Org2 Data Source ${uuidv4()}`,
81+
recordType: DataSourceRecordType.Locations,
82+
autoEnrich: false,
83+
autoImport: false,
84+
public: false,
85+
config: { type: DataSourceType.CSV, url: "https://example.com/org2.csv" },
86+
columnDefs: [{ name: "name", type: ColumnType.String }],
87+
columnMetadata: [],
88+
columnRoles: { nameColumns: ["name"] },
89+
geocodingConfig: { type: GeocodingType.None },
90+
enrichments: [],
91+
organisationId: organisationTwo.id,
92+
});
93+
94+
dataSourceThree = await createDataSource({
95+
name: `Org3 Data Source ${uuidv4()}`,
96+
recordType: DataSourceRecordType.Locations,
97+
autoEnrich: false,
98+
autoImport: false,
99+
public: false,
100+
config: { type: DataSourceType.CSV, url: "https://example.com/org3.csv" },
101+
columnDefs: [{ name: "name", type: ColumnType.String }],
102+
columnMetadata: [],
103+
columnRoles: { nameColumns: ["name"] },
104+
geocodingConfig: { type: GeocodingType.None },
105+
enrichments: [],
106+
organisationId: organisationThree.id,
107+
});
108+
});
109+
110+
it("should return 401 without authentication", async () => {
111+
const response = await GET(createNextRequest({ url: endpointUrl() }));
112+
expect(response.status).toBe(401);
113+
});
114+
115+
it("should return 401 with invalid credentials", async () => {
116+
const response = await GET(
117+
createNextRequest({
118+
url: endpointUrl(),
119+
credentials: { email: testUser.email, password: "wrong-password" },
120+
}),
121+
);
122+
expect(response.status).toBe(401);
123+
});
124+
125+
it("should return readable data sources across all user organisations", async () => {
126+
const response = await GET(
127+
createNextRequest({
128+
url: endpointUrl(),
129+
credentials: { email: testUser.email, password: testPassword },
130+
}),
131+
);
132+
133+
expect(response.status).toBe(200);
134+
const body = (await response.json()) as {
135+
id: string;
136+
name: string;
137+
organisation: { id: string; name: string };
138+
}[];
139+
140+
expect(body).toEqual(
141+
expect.arrayContaining([
142+
{
143+
id: dataSourceOne.id,
144+
name: dataSourceOne.name,
145+
organisation: {
146+
id: organisationOne.id,
147+
name: organisationOne.name,
148+
},
149+
},
150+
{
151+
id: dataSourceTwo.id,
152+
name: dataSourceTwo.name,
153+
organisation: {
154+
id: organisationTwo.id,
155+
name: organisationTwo.name,
156+
},
157+
},
158+
]),
159+
);
160+
expect(body.find((item) => item.id === dataSourceThree.id)).toBeUndefined();
161+
});
162+
163+
it("should accept case-insensitive basic auth scheme with extra spaces", async () => {
164+
const response = await GET(
165+
createNextRequest({
166+
url: endpointUrl(),
167+
credentials: { email: testUser.email, password: testPassword },
168+
scheme: "basic",
169+
spacing: " ",
170+
}),
171+
);
172+
expect(response.status).toBe(200);
173+
});
174+
175+
it("should authenticate when password contains colons", async () => {
176+
const response = await GET(
177+
createNextRequest({
178+
url: endpointUrl(),
179+
credentials: {
180+
email: testUserWithColonPassword.email,
181+
password: colonPassword,
182+
},
183+
}),
184+
);
185+
expect(response.status).toBe(200);
186+
});
187+
188+
it("should reject invalid credentials when password contains colons", async () => {
189+
const response = await GET(
190+
createNextRequest({
191+
url: endpointUrl(),
192+
credentials: {
193+
email: testUserWithColonPassword.email,
194+
password: `${colonPassword}-invalid`,
195+
},
196+
}),
197+
);
198+
expect(response.status).toBe(401);
199+
});
200+
});
201+
202+
const endpointUrl = () => "http://localhost:3000/api/rest/data-sources";
203+
204+
const createNextRequest = ({
205+
url,
206+
credentials,
207+
scheme = "Basic",
208+
spacing = " ",
209+
}: {
210+
url: string;
211+
credentials?: { email: string; password: string } | null | undefined;
212+
scheme?: string;
213+
spacing?: string;
214+
}) => {
215+
const authorization = credentials
216+
? Buffer.from(`${credentials.email}:${credentials.password}`).toString(
217+
"base64",
218+
)
219+
: "";
220+
return new NextRequest(url, {
221+
headers: authorization
222+
? { Authorization: `${scheme}${spacing}${authorization}` }
223+
: {},
224+
});
225+
};

0 commit comments

Comments
 (0)