Skip to content

Commit

Permalink
feat(code-gen): define behavior for T.file() in R.body() (#2597)
Browse files Browse the repository at this point in the history
This was strictly prohibited in old code-gen, and you could only use it with `R.files()`, which restricted using other post body values.

Until now, no behavior was specified for current code-gen. This adds support for using 'T.file()' with `R.body()`. It restricts other values to only use basic types, so we don't define behavior for array or object types in multipart/form-data handling.
  • Loading branch information
dirkdev98 committed May 20, 2023
1 parent 4b7ba7c commit 80429b0
Show file tree
Hide file tree
Showing 19 changed files with 741 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/examples/file-handling.md
6 changes: 6 additions & 0 deletions examples/file-handling/.env
@@ -0,0 +1,6 @@
NODE_ENV=development
APP_NAME=file-handling

POSTGRES_HOST=127.0.0.1:5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
26 changes: 26 additions & 0 deletions examples/file-handling/README.md
@@ -0,0 +1,26 @@
# CRUD basics

This project is created using the
[file-handling](https://github.com/compasjs/compas/tree/main/examples/file-handling)
template via [create-compas](https://www.npmjs.com/package/create-compas).

```shell
# Via NPM
npx create-compas@latest --template file-handling

# Or with Yarn
yarn create compas --template file-handling
```

## Getting started

- Start up the development Postgres and Minio instances
- `compas docker up`
- Apply the Postgres migrations
- `compas migrate`
- Regenerate router, validators, types, sql, etc.
- `compas run generate`
- Run the tests
- `compas test --serial`
- Start the API
- `compas run api`
Binary file added examples/file-handling/__fixtures__/5.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions examples/file-handling/migrations/001-post.sql
@@ -0,0 +1,50 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";


CREATE TABLE "file"
(
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
"contentLength" int NOT NULL,
"bucketName" varchar NOT NULL,
"contentType" varchar NOT NULL,
"name" varchar NOT NULL,
"meta" jsonb NOT NULL,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX "fileBucketNameIdx" ON "file" ("bucketName");

CREATE TABLE "job"
(
"id" bigserial PRIMARY KEY NOT NULL,
"isComplete" boolean NOT NULL,
"priority" int NOT NULL,
"retryCount" int NOT NULL DEFAULT 0,
"name" varchar NOT NULL,
"scheduledAt" timestamptz NOT NULL DEFAULT now(),
"data" jsonb NOT NULL,
"handlerTimeout" int NULL,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX "jobIsCompleteScheduledAtIdx" ON "job" ("isComplete", "scheduledAt");
CREATE INDEX "jobNameIdx" ON "job" ("name");
CREATE INDEX "jobScheduledAtIdx" ON "job" ("scheduledAt");
CREATE INDEX IF NOT EXISTS "jobIsCompleteUpdatedAt" ON "job" ("isComplete", "updatedAt") WHERE "isComplete" IS TRUE;


CREATE TABLE "post"
(
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
"contents" varchar NOT NULL,
"title" varchar NOT NULL,
"headerImage" uuid NULL,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now(),
constraint "postHeaderImageFk" foreign key ("headerImage") references "file" ("id") ON DELETE SET NULL
);

CREATE INDEX "postDatesIdx" ON "post" ("createdAt", "updatedAt");
CREATE INDEX "postTitleIdx" ON "post" ("title");
23 changes: 23 additions & 0 deletions examples/file-handling/package.json
@@ -0,0 +1,23 @@
{
"private": true,
"version": "0.0.1",
"type": "module",
"dependencies": {
"@compas/cli": "*",
"@compas/server": "*",
"@compas/stdlib": "*",
"@compas/store": "*"
},
"devDependencies": {
"@compas/code-gen": "*",
"@compas/eslint-plugin": "*"
},
"prettier": "@compas/eslint-plugin/prettierrc",
"exampleMetadata": {
"generating": "compas run generate",
"testing": [
"compas migrate",
"compas test --serial"
]
}
}
18 changes: 18 additions & 0 deletions examples/file-handling/scripts/api.js
@@ -0,0 +1,18 @@
import { environment, isProduction, isStaging, mainFn } from "@compas/stdlib";
import { app, injectServices } from "../src/services.js";

mainFn(import.meta, main);

async function main(logger) {
await injectServices();

const port = Number(environment.PORT ?? "3001");
app.listen(port, () => {
logger.info({
message: "Listening...",
port,
isProduction: isProduction(),
isStaging: isStaging(),
});
});
}
88 changes: 88 additions & 0 deletions examples/file-handling/scripts/generate.js
@@ -0,0 +1,88 @@
import { TypeCreator, Generator } from "@compas/code-gen";
import { mainFn } from "@compas/stdlib";
import { storeGetStructure } from "@compas/store";

mainFn(import.meta, main);

function main() {
const generator = new Generator();
const T = new TypeCreator("post");
const R = T.router("/post");
const Tdatabase = new TypeCreator("database");

generator.addStructure(storeGetStructure());

generator.add(
Tdatabase.object("post")
.keys({
title: T.string().min(3).searchable(),
contents: T.string(),
})
.enableQueries({
withDates: true,
})
.relations(
T.oneToOne(
"headerImage",
T.reference("store", "file"),
"postHeaderImage",
).optional(),
),

R.get("/list", "list").response({
posts: [
{
id: T.uuid(),
title: T.string(),
contents: T.string(),
headerImage: T.reference("store", "fileResponse").optional(),
},
],
}),

R.post("/create", "create")
.body({
title: T.string(),
contents: T.string(),
headerImage: T.file().optional(),
})
.response({}),

R.get("/:id/header-image", "headerImage")
.params({
id: T.uuid(),
})
.query(T.reference("store", "imageTransformOptions"))
.response(T.file()),
);

generator.generate({
targetLanguage: "js",
outputDirectory: "./src/generated",
generators: {
database: {
target: {
dialect: "postgres",
includeDDL: true,
},
},
apiClient: {
target: {
targetRuntime: "node.js",
library: "fetch",
},
responseValidation: {
looseObjectValidation: false,
},
},
types: {
declareGlobalTypes: true,
},
router: {
target: {
library: "koa",
},
},
},
});
}
146 changes: 146 additions & 0 deletions examples/file-handling/src/services.js
@@ -0,0 +1,146 @@
import { createBodyParser, getApp } from "@compas/server";
import {
AppError,
environment,
isNil,
isProduction,
uuid,
} from "@compas/stdlib";
import {
createTestPostgresDatabase,
fileCreateOrUpdate,
fileFormatMetadata,
fileSendTransformedImageResponse,
newPostgresConnection,
objectStorageCreateClient,
objectStorageEnsureBucket,
objectStorageGetDevelopmentConfig,
} from "@compas/store";
import { queries } from "./generated/common/database.js";
import { router } from "./generated/common/router.js";
import { queryPost } from "./generated/database/post.js";

export let app = undefined;

export let sql = undefined;

export let s3Client = undefined;

export let bucketName = undefined;

export async function injectServices() {
app = getApp({});
sql = await newPostgresConnection({ max: 10 });

s3Client = objectStorageCreateClient(
isProduction() ? {} : objectStorageGetDevelopmentConfig(),
);
bucketName = environment.APP_NAME;
await objectStorageEnsureBucket(s3Client, {
bucketName,
locationConstraint: "eu-central-1",
});

await loadRoutes();

app.use(
router(
createBodyParser({
multipart: true,
}),
),
);
}

export async function injectTestServices() {
app = getApp({});
sql = await createTestPostgresDatabase();

s3Client = objectStorageCreateClient(objectStorageGetDevelopmentConfig());
bucketName = uuid();
await objectStorageEnsureBucket(s3Client, {
bucketName,
locationConstraint: "eu-central-1",
});

await loadRoutes();

app.use(
router(
createBodyParser({
multipart: true,
}),
),
);
}

/**
* Register all routes
*/
async function loadRoutes() {
const { postHandlers } = await import("./generated/post/controller.js");

postHandlers.list = async (ctx) => {
const posts = await queryPost({
headerImage: {},
}).exec(sql);

ctx.body = {
posts: posts.map((it) => ({
id: it.id,
title: it.title,
contents: it.contents,
headerImage: it.headerImage
? fileFormatMetadata(it.headerImage, {
url: `http://${ctx.request.host}/post/${it.id}/header-image`,
})
: undefined,
})),
};
};

postHandlers.create = async (ctx) => {
const { headerImage, ...props } = ctx.validatedBody;

if (headerImage) {
const file = await fileCreateOrUpdate(
sql,
s3Client,
{
bucketName,
allowedContentTypes: ["image/jpg", "image/png", "image/jpeg"],
},
{
name: headerImage.originalFilename,
},
headerImage.filepath,
);

props.headerImage = file.id;
}

await queries.postInsert(sql, props);

ctx.body = {};
};

postHandlers.headerImage = async (ctx) => {
const [post] = await queryPost({
where: {
id: ctx.validatedParams.id,
},
headerImage: {},
}).exec(sql);

if (isNil(post?.headerImage)) {
throw AppError.validationError(`post.headerImage.unknown`);
}

await fileSendTransformedImageResponse(
sql,
s3Client,
ctx,
post.headerImage,
);
};
}

0 comments on commit 80429b0

Please sign in to comment.