Skip to content

Commit

Permalink
v3: env var management API (#1116)
Browse files Browse the repository at this point in the history
* WIP env var management API

* Add import env var API endpoint

* Adding docs and support for using both API keys and PATs when interacting with the env var endpoints

* WIP envvar SDK

* Uploading env vars in a variety of formats now works

* Finish env var endpoints and add resolveEnvVars hook

* Add changeset
  • Loading branch information
ericallam committed May 23, 2024
1 parent 1f462ea commit 3a1b0c4
Show file tree
Hide file tree
Showing 44 changed files with 3,271 additions and 1,090 deletions.
7 changes: 7 additions & 0 deletions .changeset/five-toes-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---

v3: Environment variable management API and SDK, along with resolveEnvVars CLI hook
8 changes: 8 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 Management",
"command": "pnpm run management",
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
{
"type": "node",
"request": "attach",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class EnvironmentVariablesPresenter {
);

const repository = new EnvironmentVariablesRepository(this.#prismaClient);
const variables = await repository.getProject(project.id, userId);
const variables = await repository.getProject(project.id);

return {
environmentVariables: environmentVariables.map((environmentVariable) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const Variable = z.object({
type Variable = z.infer<typeof Variable>;

const schema = z.object({
overwrite: z.preprocess((i) => {
override: z.preprocess((i) => {
if (i === "true") return true;
if (i === "false") return false;
return;
Expand Down Expand Up @@ -115,6 +115,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
Expand All @@ -126,7 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}

const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.create(project.id, userId, submission.value);
const result = await repository.create(project.id, submission.value);

if (!result.success) {
if (result.variableErrors) {
Expand Down Expand Up @@ -249,18 +256,18 @@ export default function Page() {
type="submit"
variant="primary/small"
disabled={isLoading}
name="overwrite"
name="override"
value="false"
>
{isLoading ? "Saving" : "Save"}
</Button>
<Button
variant="secondary/small"
disabled={isLoading}
name="overwrite"
name="override"
value="true"
>
{isLoading ? "Overwriting" : "Overwrite"}
{isLoading ? "Overriding" : "Override"}
</Button>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
Expand All @@ -119,7 +126,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (submission.value.action) {
case "edit": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.edit(project.id, userId, submission.value);
const result = await repository.edit(project.id, submission.value);

if (!result.success) {
submission.error.key = result.error;
Expand All @@ -138,7 +145,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
case "delete": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.delete(project.id, userId, submission.value);
const result = await repository.delete(project.id, submission.value);

if (!result.success) {
submission.error.key = result.error;
Expand Down Expand Up @@ -334,6 +341,7 @@ function EditEnvironmentVariablePanel({
name={`values[${index}].value`}
placeholder="Not set"
defaultValue={value}
type="password"
/>
</Fragment>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";

const ParamsSchema = z.object({
projectRef: z.string(),
slug: z.string(),
name: z.string(),
});

export async function action({ params, request }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}

const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);

// Find the environment variable
const variable = await prisma.environmentVariable.findFirst({
where: {
key: parsedParams.data.name,
projectId: environment.project.id,
},
});

if (!variable) {
return json({ error: "Environment variable not found" }, { status: 404 });
}

const repository = new EnvironmentVariablesRepository();

switch (request.method.toUpperCase()) {
case "DELETE": {
const result = await repository.deleteValue(environment.project.id, {
id: variable.id,
environmentId: environment.id,
});

if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error }, { status: 400 });
}
}
case "PUT":
case "POST": {
const jsonBody = await request.json();

const body = UpdateEnvironmentVariableRequestBody.safeParse(jsonBody);

if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}

const result = await repository.edit(environment.project.id, {
values: [
{
value: body.data.value,
environmentId: environment.id,
},
],
id: variable.id,
keepEmptyValues: true,
});

if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error }, { status: 400 });
}
}
}
}

export async function loader({ params, request }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}

const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);

// Find the environment variable
const variable = await prisma.environmentVariable.findFirst({
where: {
key: parsedParams.data.name,
projectId: environment.project.id,
},
});

if (!variable) {
return json({ error: "Environment variable not found" }, { status: 404 });
}

const repository = new EnvironmentVariablesRepository();

const variables = await repository.getEnvironment(environment.project.id, environment.id, true);

const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);

if (!environmentVariable) {
return json({ error: "Environment variable not found" }, { status: 404 });
}

return json({
value: environmentVariable.value,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3";
import { parse } from "dotenv";
import { z } from "zod";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";

const ParamsSchema = z.object({
projectRef: z.string(),
slug: z.string(),
});

export async function action({ params, request }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}

const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);

const repository = new EnvironmentVariablesRepository();

const body = await parseImportBody(request);

const result = await repository.create(environment.project.id, {
override: typeof body.override === "boolean" ? body.override : false,
environmentIds: [environment.id],
variables: Object.entries(body.variables).map(([key, value]) => ({
key,
value,
})),
});

if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error, variableErrors: result.variableErrors }, { status: 400 });
}
}

async function parseImportBody(request: Request): Promise<ImportEnvironmentVariablesRequestBody> {
const contentType = request.headers.get("content-type") ?? "application/json";

if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();

const file = formData.get("variables");
const override = formData.get("override") === "true";

if (file instanceof File) {
const buffer = await file.arrayBuffer();

const variables = parse(Buffer.from(buffer));

return { variables, override };
} else {
throw json({ error: "Invalid file" }, { status: 400 });
}
} else {
const rawBody = await request.json();

const body = ImportEnvironmentVariablesRequestBody.safeParse(rawBody);

if (!body.success) {
throw json({ error: "Invalid body" }, { status: 400 });
}

return body.data;
}
}
Loading

0 comments on commit 3a1b0c4

Please sign in to comment.