Skip to content

Commit

Permalink
feat: models metadtaa saving
Browse files Browse the repository at this point in the history
  • Loading branch information
SmallhillCZ committed May 31, 2024
1 parent ac98eaa commit 306c1ff
Show file tree
Hide file tree
Showing 18 changed files with 489 additions and 127 deletions.
18 changes: 18 additions & 0 deletions studio/app/api/models/[model]/data/[file]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { downloadOwnModelFile } from "@/features/models";
import mime from "mime";
import { Readable } from "stream";

export async function GET(
req: Request,
{ params }: { params: { model: string; file: string } }
) {
const fileStream: ReadableStream = Readable.toWeb(
await downloadOwnModelFile(parseInt(params.model), params.file)
) as ReadableStream;

return new Response(fileStream, {
headers: {
"Content-Type": mime.getType(params.model) ?? "application/octet-stream",
},
});
}
Empty file.
28 changes: 21 additions & 7 deletions studio/app/api/models/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { saveModel } from "@/features/models";
import { Readable } from "node:stream";
import { ReadableStream } from "stream/web";
import { createOwnModel } from "@/features/models";
import { z } from "zod";
import { zfd } from "zod-form-data";

const postSchema = zfd.formData({
file: zfd.file(),
file: zfd.repeatableOfType(zfd.file()),
name: zfd.text(),
coordinateSystem: zfd.text().optional(),
});

export async function POST(req: Request) {
const data = postSchema.parse(await req.formData());
try {
const data = postSchema.parse(await req.formData());

const fileStream = Readable.fromWeb(data.file.stream() as ReadableStream);
const model = await createOwnModel(
{
name: data.name,
coordinateSystem: data.coordinateSystem,
},
data.file
);

await saveModel(fileStream);
return Response.json(model, { status: 201 });
} catch (e) {
if (e instanceof z.ZodError) {
return new Response(e.message, { status: 400 });
}
throw e;
}
}
3 changes: 3 additions & 0 deletions studio/features/auth/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { getSession } from "@auth0/nextjs-auth0";

export const getUserToken = async () => {
const session = await getSession();

if (!session?.user?.sub) return null;

return {
id: session?.user?.sub,
email: session?.user?.email,
Expand Down
3 changes: 2 additions & 1 deletion studio/features/db/data-source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "reflect-metadata";
import { DataSource } from "typeorm";
import { Config } from "../config";
import { Model } from "./entities/model";
import { Project } from "./entities/project";
import { User } from "./entities/user";

Expand All @@ -13,7 +14,7 @@ export const AppDataSource = new DataSource({
database: Config.db.database,
synchronize: true,
logging: ["error", "warn"],
entities: [Project, User],
entities: [Project, User, Model],
subscribers: [],
migrations: [],
});
30 changes: 30 additions & 0 deletions studio/features/db/entities/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
import { User } from "./user";

@Entity("models")
export class Model {
@PrimaryGeneratedColumn() id!: number;

@Column() name!: string;
@Column("varchar", { nullable: true }) coordinateSystem?: string | null;

@ManyToOne(() => User, (user) => user.models, {
nullable: false,
onDelete: "RESTRICT",
onUpdate: "CASCADE",
})
user?: User;

@CreateDateColumn() createdAt!: Date;
@UpdateDateColumn() updatedAt!: Date;

// get from file system
files?: string[];
}
2 changes: 1 addition & 1 deletion studio/features/db/entities/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class Project {
@Column() description!: string;

@ManyToOne(() => User, (user) => user.projects, {
onDelete: "SET NULL",
onDelete: "RESTRICT",
onUpdate: "CASCADE",
})
user?: User;
Expand Down
4 changes: 4 additions & 0 deletions studio/features/db/entities/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Column, Entity, OneToMany, PrimaryColumn } from "typeorm";
import { Model } from "./model";
import { Project } from "./project";

@Entity()
Expand All @@ -10,4 +11,7 @@ export class User {

@OneToMany(() => Project, (project) => project.user)
projects!: Project[];

@OneToMany(() => Model, (model) => model.user)
models!: Model[];
}
56 changes: 31 additions & 25 deletions studio/features/models/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
import { Readable } from "stream";
import { describe, expect, test } from "vitest";
import {
checkFileExists,
deleteFile,
ensureDirectory,
readFile,
saveFileStream,
} from "../storage";
import { createOwnModel, deleteOwnModel, getOwnModel } from ".";
import { Model } from "../db/entities/model";

describe("model actions", () => {
const model = new Readable();
model.push("test");
model.push(null);
const modelFile = {
stream: () =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("test"));
controller.close();
},
}),
name: "test.txt",
} as File;

test("saveModel", async () => {
// Save the model file
await ensureDirectory("models");
await saveFileStream("model", "models", model);
const modelMetadata: Pick<Model, "name" | "coordinateSystem"> = {
name: "My best model",
coordinateSystem: "WGS84",
};

// Check if the file exists
const data = await readFile("model", "models");
expect(data.toString()).toBe("test");
});
test("model CRD", async () => {
let model: Model | null;

// CREATE
model = await createOwnModel(modelMetadata, [modelFile]);
expect(model).toMatchObject(modelMetadata);

test("deleteModel", async () => {
// Delete the model file
await deleteFile("model", "models");
// READ
model = await getOwnModel(model.id);
expect(model).toMatchObject(modelMetadata);
expect(model?.files).toHaveLength(1);
expect(model?.files?.[0]).toBe(modelFile.name);

// Check if the file exists
const exists = await checkFileExists("model", "models");
expect(exists).toBe(false);
// DELETE
await deleteOwnModel(model!.id);
model = await getOwnModel(model!.id);
expect(model).toBe(null);
});
});
123 changes: 119 additions & 4 deletions studio/features/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,124 @@
import { Readable } from "stream";
import { canCreateModel } from "../auth/acl";
import { saveFileStream } from "../storage";
import { ReadableStream } from "stream/web";
import { canCreateModel, canReadOwnModels } from "../auth/acl";
import { getUserToken } from "../auth/user";
import { Model } from "../db/entities/model";
import { injectRepository } from "../db/helpers";
import {
deleteFile,
ensureDirectory,
getUserModelDirectory,
listFilesInDirectory,
readFileStream,
saveFileStream,
} from "../storage";

export async function saveModel(model: Readable) {
export async function createOwnModel(
metadata: Partial<Pick<Model, "name" | "coordinateSystem">>,
files: File[]
) {
if (!(await canCreateModel())) throw new Error("Unauthorized");

await saveFileStream("model", "models", model);
const user = (await getUserToken())!;

const modelRepository = await injectRepository(Model);

const model = await modelRepository.save({
...metadata,
user: { id: user.id },
});

try {
// save files
const dir = getUserModelDirectory(user.id, model.id);

await ensureDirectory(dir);

for (const file of files) {
const fileStream = Readable.fromWeb(file.stream() as ReadableStream);
await saveFileStream(file.name, dir, fileStream);
}
} catch (e) {
await modelRepository.remove(model);
throw e;
}

return {
...model,
files: files.map((f) => f.name),
};
}

export async function downloadOwnModelFile(modelId: number, fileName: string) {
if (!(await canReadOwnModels())) throw new Error("Unauthorized");

const user = (await getUserToken())!;

const modelRepository = await injectRepository(Model);

const model = await modelRepository.findOne({
where: { id: modelId, user: { id: user.id } },
});
if (!model) throw new Error("Not found");

const dir = getUserModelDirectory(user.id, model.id);

return await readFileStream(fileName, dir);
}

export async function deleteOwnModel(modelId: number) {
if (!(await canReadOwnModels())) throw new Error("Unauthorized");

const user = (await getUserToken())!;

const modelRepository = await injectRepository(Model);

const model = await modelRepository.findOne({
where: { id: modelId, user: { id: user.id } },
});
if (!model) throw new Error("Not found");

// delete files
const dir = getUserModelDirectory(user.id, model.id);

const files = await listFilesInDirectory(dir);
for (const file of files) {
await deleteFile(file, dir);
}

// delete model
await modelRepository.remove(model);
}

export async function listOwnModels() {
if (!(await canReadOwnModels())) throw new Error("Unauthorized");

const user = (await getUserToken())!;

const modelRepository = await injectRepository(Model);

return await modelRepository.find({
where: { user: { id: user.id } },
});
}

export async function getOwnModel(modelId: number) {
if (!(await canReadOwnModels())) throw new Error("Unauthorized");

const user = (await getUserToken())!;

const modelRepository = await injectRepository(Model);

const model = await modelRepository.findOne({
where: { id: modelId, user: { id: user.id } },
});
if (!model) return null;

const dir = getUserModelDirectory(user.id, model.id);
const files = await listFilesInDirectory(dir);

return {
...model,
files,
};
}
29 changes: 10 additions & 19 deletions studio/features/projects/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,34 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { createProject, deleteProject, getProjectById, updateProject } from ".";
import { pick } from "../helpers/objects";

vi.mock("@auth0/nextjs-auth0", () => ({
getSession: async () => ({
user: {
sub: "test",
email: "test@test",
picture: "https://example.com/picture.png",
},
}),
}));
import { Project } from "../db/entities/project";

describe("project actions", () => {
let projectId: number;

test("Create a project", async () => {
const project = await createProject({
test("project CRUD", async () => {
// CREATE
let project: Project | null = await createProject({
name: "Test Project",
description: "This is a test project",
});

projectId = project.id;

expect(pick(project, ["name", "description"])).toEqual({
// READ
project = await getProjectById(projectId);
expect(project).toMatchObject({
name: "Test Project",
description: "This is a test project",
});
});

test("Update a project", async () => {
// UPDATE
const updatedProject = await updateProject(projectId, {
description: "This is an updated test project",
});

expect(updatedProject?.description).toBe("This is an updated test project");
});

test("Delete a project", async () => {
// DELETE
await deleteProject(projectId);

expect(await getProjectById(projectId)).toBe(null);
Expand Down
Loading

0 comments on commit 306c1ff

Please sign in to comment.