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: allow-developer-to-set-a-custom-refill-time-when-using-the #2114

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6ed290f
refill day working on dashboard
MichaelUnkey Sep 18, 2024
17e48c1
First draft refill route changes
MichaelUnkey Sep 19, 2024
b14c926
fmt
MichaelUnkey Sep 19, 2024
8472e31
Merge branch 'main' into eng-1324-allow-developer-to-set-a-custom-ref…
MichaelUnkey Sep 19, 2024
8d07b05
Merge branch 'main' of https://github.com/unkeyed/unkey into eng-1324…
MichaelUnkey Sep 19, 2024
0904817
Merge branch 'eng-1324-allow-developer-to-set-a-custom-refill-time-wh…
MichaelUnkey Sep 19, 2024
463f07d
working create
MichaelUnkey Sep 23, 2024
ac4ccaf
Merge branch 'main' of https://github.com/unkeyed/unkey into eng-1324…
MichaelUnkey Sep 23, 2024
2dce62d
Docs
MichaelUnkey Sep 23, 2024
2a76eb6
Merge branch 'main' into eng-1324-allow-developer-to-set-a-custom-ref…
MichaelUnkey Sep 23, 2024
c35d5d3
Merge branch 'main' of https://github.com/unkeyed/unkey into eng-1324…
MichaelUnkey Sep 23, 2024
f2647e1
Merge branch 'eng-1324-allow-developer-to-set-a-custom-refill-time-wh…
MichaelUnkey Sep 23, 2024
85d2da3
reqeusted changes part one
MichaelUnkey Sep 25, 2024
ae98c89
Merge branch 'main' of https://github.com/unkeyed/unkey into eng-1324…
MichaelUnkey Sep 25, 2024
29558b5
tests added
MichaelUnkey Sep 26, 2024
616d9a0
Merge branch 'main' into eng-1324-allow-developer-to-set-a-custom-ref…
MichaelUnkey Sep 26, 2024
71c668c
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 26, 2024
da8b292
Fixed errors made changes from comments
MichaelUnkey Sep 26, 2024
cb283dc
missed file
MichaelUnkey Sep 26, 2024
48635cd
Merge branch 'main' of https://github.com/unkeyed/unkey into eng-1324…
MichaelUnkey Sep 26, 2024
6476ba5
store changes for review not complete
MichaelUnkey Sep 27, 2024
e17617b
test changes
MichaelUnkey Sep 27, 2024
eb4df2e
test Fixes
MichaelUnkey Sep 27, 2024
d553e56
Merge branch 'main' of https://github.com/unkeyed/unkey into eng-1324…
MichaelUnkey Sep 27, 2024
666c6a3
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 27, 2024
7446529
api test fix
MichaelUnkey Sep 27, 2024
0997a90
Merge branch 'eng-1324-allow-developer-to-set-a-custom-refill-time-wh…
MichaelUnkey Sep 27, 2024
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
1 change: 1 addition & 0 deletions apps/api/src/pkg/key_migration/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export async function migrateKey(
expires: message.expires ? new Date(message.expires) : null,
refillInterval: message.refill?.interval,
refillAmount: message.refill?.amount,
refillDay: message.refill?.refillDay,
enabled: message.enabled,
remaining: message.remaining,
ratelimitAsync: message.ratelimit?.async,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/pkg/key_migration/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type MessageBody = {
permissions?: string[];
expires?: number;
remaining?: number;
refill?: { interval: "daily" | "monthly"; amount: number };
refill?: { interval: "daily" | "monthly"; amount: number; refillDay?: number };
ratelimit?: { async: boolean; limit: number; duration: number };
enabled: boolean;
environment?: string;
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/routes/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export const keySchema = z
description: "Resets `remaining` to this value every interval.",
example: 100,
}),
refillDay: z.number().min(1).max(31).default(1).nullable().openapi({
MichaelUnkey marked this conversation as resolved.
Show resolved Hide resolved
description:
"The day verifications will refill each month, when interval is set to 'monthly'. Value is not zero-indexed making 1 the first day of the month. If left blank it will default to the last day of the month.",
example: 15,
}),
lastRefillAt: z.number().int().optional().openapi({
description: "The unix timestamp in miliseconds when the key was last refilled.",
example: 100,
Expand All @@ -76,10 +81,12 @@ export const keySchema = z
description:
"Unkey allows you to refill remaining verifications on a key on a regular interval.",
example: {
interval: "daily",
interval: "monthly",
amount: 10,
refillDay: 10,
},
}),

ratelimit: z
.object({
async: z.boolean().openapi({
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/v1_apis_listKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export const registerV1ApisListKeys = (app: App) =>
? {
interval: k.refillInterval,
amount: k.refillAmount,
refillDay: k.refillInterval === "monthly" && k.refillDay ? k.refillDay : null,
lastRefillAt: k.lastRefillAt?.getTime(),
}
: undefined,
Expand Down
33 changes: 32 additions & 1 deletion apps/api/src/routes/v1_keys_createKey.error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type { V1KeysCreateKeyRequest, V1KeysCreateKeyResponse } from "./v1_keys_
test("when the api does not exist", async (t) => {
const h = await IntegrationHarness.init(t);
const apiId = newId("api");

const root = await h.createRootKey([`api.${apiId}.create_key`]);
/* The code snippet is making a POST request to the "/v1/keys.createKey" endpoint with the specified headers. It is using the `h.post` method from the `Harness` instance to send the request. The generic types `<V1KeysCreateKeyRequest, V1KeysCreateKeyResponse>` specify the request payload and response types respectively. */

Expand Down Expand Up @@ -119,3 +118,35 @@ test("when key recovery is not enabled", async (t) => {
},
});
});

test("reject invalid refill config when daily interval has non-null refillDay", async (t) => {
const h = await IntegrationHarness.init(t);

const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]);

const res = await h.post<V1KeysCreateKeyRequest, V1KeysCreateKeyResponse>({
url: "/v1/keys.createKey",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
byteLength: 16,
apiId: h.resources.userApi.id,
remaining: 10,
refill: {
amount: 100,
refillDay: 4,
interval: "daily",
},
},
});
expect(res.status).toEqual(400);
expect(res.body).toMatchObject({
error: {
code: "BAD_REQUEST",
docs: "https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST",
message: "when interval is set to 'daily', 'refillDay' must be null.",
},
});
});
33 changes: 33 additions & 0 deletions apps/api/src/routes/v1_keys_createKey.happy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,4 +467,37 @@ describe("with externalId", () => {
expect(key!.identity!.id).toEqual(identity.id);
});
});
describe("Should default last day of month if none provided", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you explain this test case to me?

Let me explain my thought process:

If this were to run today:

const date = new Date();
// -> 2024-09-26T19:37:51.482Z
const lastDate = date.getMonth() + 1;
// -> 9 (october but 0 indexed)

Why do we want the default `refillDay` to be 9?
      

test("should provide default value", async (t) => {
const h = await IntegrationHarness.init(t);
const date = new Date();
const lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]);

const res = await h.post<V1KeysCreateKeyRequest, V1KeysCreateKeyResponse>({
url: "/v1/keys.createKey",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
apiId: h.resources.userApi.id,
remaining: 10,
refill: {
interval: "monthly",
amount: 20,
refillDay: undefined,
},
},
});

expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200);

const key = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, res.body.keyId),
});
expect(key).toBeDefined();
expect(key!.refillDay).toEqual(lastDate);
});
});
});
28 changes: 27 additions & 1 deletion apps/api/src/routes/v1_keys_createKey.ts
chronark marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,27 @@ When validating a key, we will return this back to you, so you can clearly ident
description:
"The number of verifications to refill for each occurrence is determined individually for each key.",
}),
refillDay: z
.number()
.min(1)
.max(31)
.optional()
.openapi({
description: `The day of the month, when we will refill the remaining verifications. To refill on the 15th of each month, set 'refillDay': 15.
If the day does not exist, for example you specified the 30th and it's february, we will refill them on the last day of the month instead.`,
}),
})
.optional()
.openapi({
description:
"Unkey enables you to refill verifications for each key at regular intervals.",
example: {
interval: "daily",
interval: "monthly",
amount: 100,
refillDay: 15,
},
}),

ratelimit: z
.object({
async: z
Expand Down Expand Up @@ -309,6 +320,12 @@ export const registerV1KeysCreateKey = (app: App) =>
message: "remaining must be set if you are using refill.",
});
}
if (req.refill?.refillDay && req.refill.interval === "daily") {
throw new UnkeyApiError({
code: "BAD_REQUEST",
message: "when interval is set to 'daily', 'refillDay' must be null.",
});
}
/**
* Set up an api for production
*/
Expand All @@ -325,7 +342,15 @@ export const registerV1KeysCreateKey = (app: App) =>
? upsertIdentity(db.primary, authorizedWorkspaceId, externalId)
: Promise.resolve(null),
]);
const date = new Date();
let lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
const newKey = await retry(5, async (attempt) => {
if (req.refill?.refillDay && req?.refill?.refillDay <= lastDayOfMonth) {
if (req.refill.interval === "monthly") {
lastDayOfMonth = req.refill.refillDay;
}
}

if (attempt > 1) {
logger.warn("retrying key creation", {
attempt,
Expand Down Expand Up @@ -357,6 +382,7 @@ export const registerV1KeysCreateKey = (app: App) =>
ratelimitDuration: req.ratelimit?.duration ?? req.ratelimit?.refillInterval,
remaining: req.remaining,
refillInterval: req.refill?.interval,
refillDay: req.refill?.interval === "monthly" ? lastDayOfMonth : null,
refillAmount: req.refill?.amount,
lastRefillAt: req.refill?.interval ? new Date() : null,
deletedAt: null,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/v1_keys_getKey.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

add test case please

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

added

Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const registerV1KeysGetKey = (app: App) =>
? {
interval: key.refillInterval,
amount: key.refillAmount,
refillDay: key.refillInterval === "monthly" ? key.refillDay : null,
lastRefillAt: key.lastRefillAt?.getTime(),
}
: undefined,
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/routes/v1_keys_updateKey.error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, test } from "vitest";
import { newId } from "@unkey/id";
import { IntegrationHarness } from "src/pkg/testutil/integration-harness";

import type { ErrorResponse } from "@/pkg/errors";
import type { V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse } from "./v1_keys_updateKey";

test("when the key does not exist", async (t) => {
Expand Down Expand Up @@ -31,3 +32,34 @@ test("when the key does not exist", async (t) => {
},
});
});
test("reject invalid refill config", async (t) => {
const h = await IntegrationHarness.init(t);
const keyId = newId("test");
const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]);
/* The code snippet is making a POST request to the "/v1/keys.createKey" endpoint with the specified headers. It is using the `h.post` method from the `Harness` instance to send the request. The generic types `<V1KeysCreateKeyRequest, V1KeysCreateKeyResponse>` specify the request payload and response types respectively. */

const res = await h.post<V1KeysUpdateKeyRequest, ErrorResponse>({
url: "/v1/keys.updateKey",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
keyId,
remaining: 10,
refill: {
amount: 100,
refillDay: 4,
interval: "daily",
},
},
});
expect(res.status).toEqual(400);
expect(res.body).toMatchObject({
error: {
code: "BAD_REQUEST",
docs: "https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST",
message: "when interval is set to 'daily', 'refillDay' must be null.",
},
});
});
46 changes: 46 additions & 0 deletions apps/api/src/routes/v1_keys_updateKey.happy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,3 +1013,49 @@ test("update ratelimit should not disable it", async (t) => {
expect(verify.body.ratelimit!.limit).toBe(5);
expect(verify.body.ratelimit!.remaining).toBe(4);
});
describe("When refillDay is omitted.", () => {
test("should provide default value", async (t) => {
const h = await IntegrationHarness.init(t);

const key = {
id: newId("test"),
keyAuthId: h.resources.userKeyAuth.id,
workspaceId: h.resources.userWorkspace.id,
start: "test",
name: "test",
hash: await sha256(new KeyV1({ byteLength: 16 }).toString()),

createdAt: new Date(),
};
await h.db.primary.insert(schema.keys).values(key);
const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]);
const res = await h.post<V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse>({
url: "/v1/keys.updateKey",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
keyId: key.id,
remaining: 10,
refill: {
interval: "monthly",
amount: 130,
refillDay: undefined,
},
enabled: true,
},
});

expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200);

const found = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, key.id),
});
expect(found).toBeDefined();
expect(found?.remaining).toEqual(10);
expect(found?.refillAmount).toEqual(130);
expect(found?.refillInterval).toEqual("monthly");
expect(found?.refillDay).toEqual(1);
});
});
20 changes: 16 additions & 4 deletions apps/api/src/routes/v1_keys_updateKey.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

add test case please

Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ This field will become required in a future version.`,
description:
"The amount of verifications to refill for each occurrence is determined individually for each key.",
}),
refillDay: z.number().min(1).max(31).optional().openapi({
description:
"The day verifications will refill each month, when interval is set to 'monthly'",
}),
})
.nullable()
.optional()
Expand Down Expand Up @@ -276,9 +280,7 @@ export const registerV1KeysUpdate = (app: App) =>
app.openapi(route, async (c) => {
const req = c.req.valid("json");
const { cache, db, usageLimiter, analytics, rbac } = c.get("services");

const auth = await rootKeyAuth(c);

const key = await db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, req.keyId),
with: {
Expand Down Expand Up @@ -335,7 +337,12 @@ export const registerV1KeysUpdate = (app: App) =>
message: "Cannot set refill on a key with unlimited requests",
});
}

if (req.refill?.refillDay && req.refill.interval === "daily") {
throw new UnkeyApiError({
code: "BAD_REQUEST",
message: "when interval is set to 'daily', 'refillDay' must be null.",
});
}
const authorizedWorkspaceId = auth.authorizedWorkspaceId;
const rootKeyId = auth.key.id;

Expand All @@ -346,7 +353,11 @@ export const registerV1KeysUpdate = (app: App) =>
: externalId === null
? null
: (await upsertIdentity(db.primary, authorizedWorkspaceId, externalId)).id;

const date = new Date();
let lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
Copy link
Collaborator

Choose a reason for hiding this comment

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

same questions, I don't understand why we use the last day

if (req.refill?.refillDay && req?.refill?.refillDay <= lastDayOfMonth) {
lastDayOfMonth = req.refill.refillDay;
}
await db.primary
.update(schema.keys)
.set({
Expand Down Expand Up @@ -377,6 +388,7 @@ export const registerV1KeysUpdate = (app: App) =>
: req.ratelimit?.duration ?? req.ratelimit?.refillInterval ?? null,
refillInterval: req.refill === null ? null : req.refill?.interval,
refillAmount: req.refill === null ? null : req.refill?.amount,
refillDay: req.refill?.interval === "monthly" ? lastDayOfMonth : null,
lastRefillAt: req.refill == null || req.refill?.amount == null ? null : new Date(),
enabled: req.enabled,
})
Expand Down
36 changes: 36 additions & 0 deletions apps/api/src/routes/v1_migrations_createKey.error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,39 @@ test("reject invalid ratelimit config", async (t) => {
expect(res.status).toEqual(400);
expect(res.body.error.code).toEqual("BAD_REQUEST");
});
test("reject invalid refill config when daily interval has non-null refillDay", async (t) => {
const h = await IntegrationHarness.init(t);
const { key } = await h.createRootKey(["*"]);

const res = await h.post<V1MigrationsCreateKeysRequest, ErrorResponse>({
url: "/v1/migrations.createKeys",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`,
},
body: [
{
start: "x",
hash: {
value: "x",
variant: "sha256_base64",
},
apiId: h.resources.userApi.id,
remaining: 10,
refill: {
amount: 100,
refillDay: 4,
interval: "daily",
},
},
],
});
expect(res.status).toEqual(400);
expect(res.body).toMatchObject({
error: {
code: "BAD_REQUEST",
docs: "https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST",
message: "when interval is set to 'daily', 'refillDay' must be null.",
},
});
});
Loading
Loading