Skip to content

Commit

Permalink
feat: add new v2 config route
Browse files Browse the repository at this point in the history
  • Loading branch information
cstrnt committed Aug 15, 2024
1 parent d5b6ee1 commit b876074
Show file tree
Hide file tree
Showing 29 changed files with 810 additions and 181 deletions.
4 changes: 3 additions & 1 deletion apps/web/abby.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ export default defineConfig(
projectId: process.env.NEXT_PUBLIC_ABBY_PROJECT_ID!,
currentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
apiUrl: process.env.NEXT_PUBLIC_ABBY_API_URL,
__experimentalCdnUrl: process.env.NEXT_PUBLIC_ABBY_CDN_URL,
debug: process.env.NEXT_PUBLIC_ABBY_DEBUG === "true",
experimental: {
apiVersion: "v2",
},
},
{
environments: ["development", "production"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `ApiRequest` MODIFY `apiVersion` ENUM('V0', 'V1', 'V2') NOT NULL DEFAULT 'V0';
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ enum ApiRequestType {
enum ApiVersion {
V0
V1
V2
}

model ApiRequest {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { logger } from "hono/logger";
import { makeHealthRoute } from "./routes/health";
import { makeLegacyProjectDataRoute } from "./routes/legacy_project_data";
import { makeEventRoute } from "./routes/v1_event";
import { makeV2ProjectDataRoute } from "./routes/v2_project_data";

export const app = new Hono()
.basePath("/api")
Expand All @@ -19,4 +20,6 @@ export const app = new Hono()
// v1 routes
.route("/v1/config", makeConfigRoute())
.route("/v1/data", makeProjectDataRoute())
.route("/v1/track", makeEventRoute());
.route("/v1/track", makeEventRoute())
// v2 routes
.route("/v2/data", makeV2ProjectDataRoute());
13 changes: 11 additions & 2 deletions apps/web/src/api/routes/v1_project_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ async function getAbbyResponseWithCache({
c: Context;
}) {
startTime(c, "readCache");
const cachedConfig = ConfigCache.getConfig({ environment, projectId });
const cachedConfig = ConfigCache.getConfig({
environment,
projectId,
apiVersion: "v1",
});
endTime(c, "readCache");

c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS");
Expand Down Expand Up @@ -72,7 +76,12 @@ async function getAbbyResponseWithCache({
}),
} satisfies AbbyDataResponse;

ConfigCache.setConfig({ environment, projectId, value: response });
ConfigCache.setConfig({
environment,
projectId,
value: response,
apiVersion: "v1",
});
return response;
}

Expand Down
190 changes: 190 additions & 0 deletions apps/web/src/api/routes/v2_project_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { zValidator } from "@hono/zod-validator";
import {
ABBY_WINDOW_KEY,
hashStringToInt32,
serializeAbbyData,
type AbbyData,
} from "@tryabby/core";
import { type Context, Hono } from "hono";
import { cors } from "hono/cors";
import { endTime, startTime, timing } from "hono/timing";
import { transformFlagValue } from "lib/flags";
import { ConfigCache } from "server/common/config-cache";
import { prisma } from "server/db/client";
import { afterDataRequestQueue } from "server/queue/queues";
import { z } from "zod";

export const X_ABBY_CACHE_HEADER = "X-Abby-Cache";

async function getAbbyResponseWithCache({
environment,
projectId,
c,
}: {
environment: string;
projectId: string;
c: Context;
}) {
startTime(c, "readCache");
const cachedConfig = ConfigCache.getConfig({
environment,
projectId,
apiVersion: "v2",
});
endTime(c, "readCache");

c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS");
if (cachedConfig) {
return serializeAbbyData(cachedConfig as AbbyData);
}

startTime(c, "db");
const [dbTests, dbFlags] = await Promise.all([
prisma.test.findMany({
where: {
projectId,
},
include: { options: { select: { chance: true } } },
}),
prisma.featureFlagValue.findMany({
where: {
environment: {
name: environment,
projectId,
},
},
include: { flag: { select: { name: true, type: true } } },
}),
]);
endTime(c, "db");

const flags = dbFlags.filter(({ flag }) => flag.type === "BOOLEAN");

const remoteConfigs = dbFlags.filter(({ flag }) => flag.type !== "BOOLEAN");

const response = {
tests: dbTests.map((test) => ({
name: hashStringToInt32(test.name).toString(),
weights: test.options.map((o) => o.chance.toNumber()),
})),
flags: flags.map((flagValue) => {
return {
name: hashStringToInt32(flagValue.flag.name).toString(),
value: transformFlagValue(flagValue.value, flagValue.flag.type),
};
}),
remoteConfig: remoteConfigs.map((flagValue) => {
return {
name: hashStringToInt32(flagValue.flag.name).toString(),
value: transformFlagValue(flagValue.value, flagValue.flag.type),
};
}),
} satisfies AbbyData;

ConfigCache.setConfig({
environment,
projectId,
value: response,
apiVersion: "v2",
});
return serializeAbbyData(response);
}

export function makeV2ProjectDataRoute() {
const app = new Hono()
.get(
"/:projectId",
cors({
origin: "*",
maxAge: 86400,
}),
zValidator(
"query",
z.object({
environment: z.string(),
})
),
timing(),
async (c) => {
const projectId = c.req.param("projectId");
const { environment } = c.req.valid("query");

const now = performance.now();

try {
startTime(c, "getAbbyResponseWithCache");
const response = await getAbbyResponseWithCache({
projectId,
environment,
c,
});
endTime(c, "getAbbyResponseWithCache");

const duration = performance.now() - now;

afterDataRequestQueue.add("after-data-request", {
apiVersion: "V2",
functionDuration: duration,
projectId,
});

return c.json(response);
} catch (e) {
console.error(e);
return c.json({ error: "Internal server error" }, { status: 500 });
}
}
)
.get(
"/:projectId/script.js",
cors({
origin: "*",
maxAge: 86400,
}),
zValidator(
"query",
z.object({
environment: z.string(),
})
),
timing(),
async (c) => {
const projectId = c.req.param("projectId");
const { environment } = c.req.valid("query");

const now = performance.now();

try {
startTime(c, "getAbbyResponseWithCache");
const response = await getAbbyResponseWithCache({
projectId,
environment,
c,
});
endTime(c, "getAbbyResponseWithCache");

const jsContent = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify(
response
)}`;

const duration = performance.now() - now;

afterDataRequestQueue.add("after-data-request", {
apiVersion: "V2",
functionDuration: duration,
projectId,
});

return c.text(jsContent, {
headers: {
"Content-Type": "application/javascript",
},
});
} catch (e) {
console.error(e);
return c.json({ error: "Internal server error" }, { status: 500 });
}
}
);
return app;
}
7 changes: 6 additions & 1 deletion apps/web/src/components/AddFeatureFlagModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Toggle } from "./Toggle";

import { useTracking } from "lib/tracking";
import { Input } from "./ui/input";
import { SAFE_NAME_REGEX } from "@tryabby/core";

type Props = {
onClose: () => void;
Expand Down Expand Up @@ -168,7 +169,6 @@ export const AddFeatureFlagModal = ({
projectId,
isRemoteConfig,
}: Props) => {
const _inputRef = useRef<HTMLInputElement>(null);
const ctx = trpc.useContext();
const stateRef = useRef<FlagFormValues>();
const trackEvent = useTracking();
Expand Down Expand Up @@ -202,6 +202,11 @@ export const AddFeatureFlagModal = ({
if (!stateRef.current?.value) {
errors.value = "Value is required";
}

if (SAFE_NAME_REGEX.test(trimmedName) === false) {
errors.name =
"Invalid name. Only letters, numbers, and underscores are allowed.";
}
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/FlagPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip";
import { Input } from "components/ui/input";
import { useProjectId } from "lib/hooks/useProjectId";
import { EditIcon, FileEditIcon, Search, TrashIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { AiOutlinePlus } from "react-icons/ai";
import { BiInfoCircle } from "react-icons/bi";
Expand Down Expand Up @@ -188,6 +188,10 @@ export const FeatureFlagPageContent = ({
setFlags(results.map((result) => result.item));
};

useEffect(() => {
setFlags(data.flags);
}, [data.flags]);

const activeFlag = data.flags.find((flag) => flag.id === activeFlagInfo?.id);

if (data.environments.length === 0)
Expand Down
Loading

0 comments on commit b876074

Please sign in to comment.