Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Metabase module controller and service #4072

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
186 changes: 186 additions & 0 deletions api.planx.uk/modules/analytics/metabase/collection/collection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,136 @@
import { createCollectionIfDoesNotExist } from "./service.js";
import { getCollection } from "./getCollection.js";
import nock from "nock";
import { MetabaseError } from "../shared/client.js";
import { $api } from "../../../../client/index.js";
import { updateMetabaseId } from "./updateMetabaseId.js";
import { getTeamIdAndMetabaseId } from "./getTeamIdAndMetabaseId.js";
import { createCollection } from "./createCollection.js";

describe("createCollectionIfDoesNotExist", () => {
beforeEach(() => {
nock.cleanAll();
});

test("creates new collection when metabase ID doesn't exist", async () => {
// Mock getTeamIdAndMetabaseId response with null metabase_id
vi.spyOn($api.client, "request").mockResolvedValueOnce({
teams: [
{
id: 26,
name: "Barnet",
metabase_id: null,
},
],
});

// Mock Metabase API calls
const metabaseMock = nock(process.env.METABASE_URL_EXT!)
.post("/api/collection/", {
name: "Barnet",
})
.reply(200, {
id: 123,
name: "Barnet",
});

const collectionId = await createCollection({
name: "Barnet",
});

expect(collectionId).toBe(123);
expect(metabaseMock.isDone()).toBe(true);
});

test("successfully places new collection in parent", async () => {
// Mock updateMetabaseId response
vi.spyOn($api.client, "request").mockResolvedValueOnce({
update_teams: {
returning: [
{
id: 26,
name: "Barnet",
metabase_id: 123,
},
],
},
});

const testName = "Example council";
const metabaseMock = nock(process.env.METABASE_URL_EXT!);

// Mock collection creation endpoint
metabaseMock
.post("/api/collection/", {
name: testName,
parent_id: 100,
})
.reply(200, {
id: 123,
name: testName,
parent_id: 100,
});

// Mock GET request for verifying the new collection
metabaseMock.get("/api/collection/123").reply(200, {
id: 123,
name: testName,
parent_id: 100,
});

const collectionId = await createCollection({
name: testName,
parentId: 100,
});

// Check the ID is returned correctly
expect(collectionId).toBe(123);

// Verify the collection details using the service function
const collection = await getCollection(collectionId);
expect(collection.parent_id).toBe(100);
expect(metabaseMock.isDone()).toBe(true);
});

test("returns collection correctly no matter collection name case", async () => {
vi.spyOn($api.client, "request").mockResolvedValueOnce({
teams: [
{
id: 26,
name: "barnet",
metabaseId: 20,
},
],
});

const collection = await getTeamIdAndMetabaseId("BARNET");
expect(collection.metabaseId).toBe(20);
});

test("throws error if network failure", async () => {
nock(process.env.METABASE_URL_EXT!)
.get("/api/collection/")
.replyWithError("Network error occurred");

await expect(
createCollection({
name: "Test Collection",
}),
).rejects.toThrow("Network error occurred");
});

test("throws error if API error", async () => {
nock(process.env.METABASE_URL_EXT!).get("/api/collection/").reply(400, {
message: "Bad request",
});

await expect(
createCollection({
name: "Test Collection",
}),
).rejects.toThrow(MetabaseError);
});
});

describe("getTeamIdAndMetabaseId", () => {
beforeEach(() => {
Expand Down Expand Up @@ -90,3 +220,59 @@ describe("updateMetabaseId", () => {
await expect(updateMetabaseId(1, 123)).rejects.toThrow("GraphQL error");
});
});

describe("edge cases", () => {
beforeEach(() => {
nock.cleanAll();
vi.clearAllMocks();
vi.resetAllMocks();
});

test("handles missing name", async () => {
await expect(
createCollectionIfDoesNotExist({
name: "",
}),
).rejects.toThrow();
});

test("handles names with special characters", async () => {
const specialName = "@#$%^&*";

nock(process.env.METABASE_URL_EXT!).get("/api/collection/").reply(200, []);

nock(process.env.METABASE_URL_EXT!)
.post("/api/collection/", {
name: specialName,
})
.reply(200, {
id: 789,
name: specialName,
});

const collection = await createCollection({
name: specialName,
});
expect(collection).toBe(789);
});

test("handles very long names", async () => {
const longName = "A".repeat(101);

nock(process.env.METABASE_URL_EXT!).get("/api/collection/").reply(200, []);

nock(process.env.METABASE_URL_EXT!)
.post("/api/collection/", {
name: longName,
})
.reply(400, {
message: "Name too long",
});

await expect(
createCollectionIfDoesNotExist({
name: longName,
}),
).rejects.toThrow();
});
});
18 changes: 18 additions & 0 deletions api.planx.uk/modules/analytics/metabase/collection/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createCollectionIfDoesNotExist } from "./service.js";
import type { NewCollectionRequestHandler } from "./types.js";

export const MetabaseCollectionsController: NewCollectionRequestHandler =
async (_req, res) => {
try {
const params = res.locals.parsedReq.body;
const collection = await createCollectionIfDoesNotExist(params);
res.status(201).json({ data: collection });
} catch (error) {
res.status(400).json({
error:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createMetabaseClient } from "../shared/client.js";
import type { NewCollectionParams } from "./types.js";

const client = createMetabaseClient();

export async function createCollection(
params: NewCollectionParams,
): Promise<any> {

Check warning on line 8 in api.planx.uk/modules/analytics/metabase/collection/createCollection.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We want to avoid using any wherever we can

const transformedParams = {
name: params.name,
parent_id: params.parentId,
};

const response = await client.post(`/api/collection/`, transformedParams);

console.log(
zz-hh-aa marked this conversation as resolved.
Show resolved Hide resolved
`New collection: ${response.data.name}, new collection ID: ${response.data.id}`,
);
return response.data.id;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createMetabaseClient } from "../shared/client.js";
import type { GetCollectionResponse } from "./types.js";

const client = createMetabaseClient();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than instantiating multiple clients, we could follow a singleton pattern and only have one.

This pattern is used for the planx-core clients (e.g. $public or $api).

/**
* Retrieves info on a collection from Metabase, use to check a parent. Currently only used in tests but could be useful for other Metabase functionality
* @param id
* @returns
*/
export async function getCollection(
id: number,
): Promise<GetCollectionResponse> {
const response = await client.get(`/api/collection/${id}`);
return response.data;
}
33 changes: 33 additions & 0 deletions api.planx.uk/modules/analytics/metabase/collection/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { updateMetabaseId } from "./updateMetabaseId.js";
import type { NewCollectionParams } from "./types.js";
import { getTeamIdAndMetabaseId } from "./getTeamIdAndMetabaseId.js";
import { createCollection } from "./createCollection.js";

/**
* First uses name to get teams.id and .metabase_id, if present.
* If metabase_id is null, return an object that includes its id. If not, create the collection.
* @params `name` is required, but `description` and `parent_id` are optional.
* @returns `response.data`, so use dot notation to access `id` or `parent_id`.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd reconsider this comment - most of this is repeating type information which is redundant.

Comments should ideally explain the why of something, and then we can allow the code and type system to explain the how.

I actually think that if the function has a meaningful name we can drop this entire comment. What do you think?

Copy link
Collaborator Author

@zz-hh-aa zz-hh-aa Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, I think it was inexperience that led me to writing some 'what' comments for myself :)

In this case I think there was some slightly better reasoning too: that it was hard to come up with a name that also communicated the try ... getTeamIdAndMetabaseId() functionality. I've tried to update the comment (4b359abe) to explain a bit more of the why rather than the what, let me know what you think!

export async function createCollectionIfDoesNotExist(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think just createCollection is fine as the IfDoesNotExist can be safely assumed because a collection cannot can get created multiple times.

Maybe createCollectionForTeam is clearer and more meaningful?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that works!

With not creating Metabase clutter in mind, I don't know that we need to run this every time a team is created, especially if PlanX will over time take on more non-planning uses? (eg we don't need collections for demo, sandbox and other agency--eg EA--spaces). But I think that's a question that's outside of the scope of this PR. (One other thing to note is that we are currently hard-coding a parent_id for the Metabase collection, which would be in our "Councils" collection.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point - I think that the non-council, non-Metabase teams, would the exceptions generally. We can always add a guard for these or remove collections at a later date if this is an issue.

Agree it's outside the scope of this PR and something to circle back to later.

params: NewCollectionParams,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
params: NewCollectionParams,
{ slug, parentId, description }: NewCollectionParams,

We could use destructing here to save a big of complexity and mapping below.

): Promise<number> {
try {
const { metabaseId, id: teamId } = await getTeamIdAndMetabaseId(
params.name,
);

if (metabaseId) {
return metabaseId;
}

// Create new Metabase collection if !metabaseId
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Redundant

const newMetabaseId = await createCollection(params);
await updateMetabaseId(teamId, newMetabaseId);
console.log({ newMetabaseId });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: console.log()

return newMetabaseId;
} catch (error) {
console.error("Error in createCollectionIfDoesNotExist:", error);
throw error;
}
}
55 changes: 55 additions & 0 deletions api.planx.uk/modules/analytics/metabase/collection/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { z } from "zod";
import type { ValidatedRequestHandler } from "../../../../shared/middleware/validate.js";

type ApiResponse<T> = {
data?: T;
error?: string;
};

/** Interface for incoming request, in camelCase */
export interface NewCollectionParams {
name: string;
description?: string;
/** Optional; if the collection is a child of a parent, specify parent ID here */
parentId?: number;
}

/** Interface for request after transforming to snake case (Metabase takes snake while PlanX API takes camel) */
export interface MetabaseCollectionParams {
name: string;
description?: string;
parent_id?: number;
}

/** Metbase collection ID for the the "Council" collection **/
// const COUNCILS_COLLECTION_ID = 58;
Comment on lines +22 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we hitting issues here with 58 just being the ID on production and not locally and on staging?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's why it's commented out. Is there a better way to handle this in the meantime?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's three main approaches -

  • We set up COUNCILS_COLLECTION_ID as an env var and pass in a valid ID for staging/pizza/localhost - this will differ per dev so not ideal but perfectly workable as an initial step
  • We commit to Metabase being prod only, and add a middleware to all /metabase routes on our API which just exits early if we're not on production - makes testing tricky!
  • We set up Metabase on staging and sync data down

We might end up on the third option, but let's work our way there via 1 and 2 first!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the meantime no issues keeping this in place and commented out.


export const createCollectionIfDoesNotExistSchema = z.object({
body: z
.object({
name: z.string(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name is a little vague - this is actually the teamSlug right? Took me a minute to catch that - we should update this.

description: z.string().optional(),
parentId: z.number().optional(), //.default(COUNCILS_COLLECTION_ID),
})
.transform((data) => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably on me sorry for not being clear on the whole camelCase / snake_case question!

We are no correctly accepting all requests in camelCase, but our API should also only handle camelCase variables. We do need to transform it for Metabase, but we can just do that when we pass it along - it looks like this is already happening in createCollection.ts?

camelCase in from consumer → API → Some logic → Pass to metabase in snake_case → Parse response back to camelCase → Some logic → camelCase back to consumer

Happy to talk this one through if not clear 🙂

name: data.name,
description: data.description,
parent_id: data.parentId,
})),
});

export type NewCollectionRequestHandler = ValidatedRequestHandler<
typeof createCollectionIfDoesNotExistSchema,
ApiResponse<number>
>;

export interface NewCollectionResponse {
id: number;
name: string;
}

export interface GetCollectionResponse {
id: number;
name: string;
parent_id: number;
}
3 changes: 0 additions & 3 deletions api.planx.uk/modules/analytics/metabase/shared/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import axios from "axios";
import {
validateConfig,
createMetabaseClient,
MetabaseError,
} from "./client.js";
import nock from "nock";

const axiosCreateSpy = vi.spyOn(axios, "create");

describe("Metabase client", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
1 change: 1 addition & 0 deletions api.planx.uk/modules/analytics/metabase/shared/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from "axios";
import assert from "assert";
import type {
AxiosInstance,
AxiosError,
Expand Down Expand Up @@ -52,7 +53,7 @@
export const createMetabaseClient = (): AxiosInstance => {
const config = validateConfig();

const client = axios.create({

Check failure on line 56 in api.planx.uk/modules/analytics/metabase/shared/client.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

modules/send/s3/index.test.ts

TypeError: default.create is not a function ❯ Module.createMetabaseClient modules/analytics/metabase/shared/client.ts:56:24 ❯ modules/analytics/metabase/collection/createCollection.ts:4:16 ❯ modules/analytics/metabase/collection/service.ts:3:31
baseURL: config.baseURL,
headers: {
"X-API-Key": config.apiKey,
Expand Down
7 changes: 7 additions & 0 deletions api.planx.uk/modules/analytics/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
logUserExitController,
logUserResumeController,
} from "./analyticsLog/controller.js";
import { MetabaseCollectionsController } from "./metabase/collection/controller.js";
import { createCollectionIfDoesNotExistSchema } from "./metabase/collection/types.js";

const router = Router();

Expand All @@ -18,5 +20,10 @@ router.post(
validate(logAnalyticsSchema),
logUserResumeController,
);
router.post(
"/collection",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API docs (docs.yaml) need to be updated each time we create a new route. There's (currently!) no automated step for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also contextualise this a little more - currently the route is just api.editors.planx.dev/collection.

It would be useful I think to prefix all of the Metabase specific paths, e.g. api.editors.planx.dev/metabase/collection.

I actually think that api.editors.planx.dev/metabase/:teamSlug/collection (instead of using name in the request body) might be a good naming convention - it's clear we're creating a collection for a team.

Later routes could be api.editors.planx.dev/metabase/:teamSlug/collection/:collectionId/dashboard etc

Copy link
Collaborator Author

@zz-hh-aa zz-hh-aa Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have sorted this (f4dea96), though I went with collection/:teamSlug--does that order seem important?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Order here is totally fine 👍

validate(createCollectionIfDoesNotExistSchema),
MetabaseCollectionsController,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
MetabaseCollectionsController,
metabaseCollectionsController,

Functions should have camelCase names, only types, interfaces and classes should be in PascalCase.

I'll see if I can find an ESLint rule for this.

);

export default router;
Loading