Skip to content

Commit

Permalink
feat(uploads): Add setup command (redwoodjs#11423)
Browse files Browse the repository at this point in the history
  • Loading branch information
dac09 authored Sep 6, 2024
1 parent e5bcec4 commit eae1e98
Show file tree
Hide file tree
Showing 10 changed files with 433 additions and 9 deletions.
66 changes: 57 additions & 9 deletions docs/docs/uploads.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,25 @@ Let's first run the setup command:
yarn rw setup uploads
```

Which generates the following configuration file:
This will do three things:

1. Generate a configuration file in `api/src/lib/uploads.{ts,js}`
2. Configure your Prisma client with the storage extension
3. Generate a signedUrl function

Let's break down the key components of the configuration.

```ts title="api/src/lib/uploads.ts"
import { UploadsConfig, setupStorage } from '@redwoodjs/storage'
import { createUploadsConfig, setupStorage } from '@redwoodjs/storage'
import { FileSystemStorage } from '@redwoodjs/storage/FileSystemStorage'
import { UrlSigner } from '@redwoodjs/storage/signedUrl'

// ⭐ (1)
const uploadConfig: UploadsConfig = {
const uploadConfig = createUploadsConfig({
profile: {
fields: ['avatar'], // 👈 the fields that will contain your `File`s
},
}
})

// ⭐ (2)
export const fsStorage = new FileSystemStorage({
Expand All @@ -219,12 +225,10 @@ const { saveFiles, storagePrismaExtension } = setupStorage({
export { saveFiles, storagePrismaExtension }
```

Let's break down the key components of this configuration.

**1. Upload Configuration**
This is where you configure the fields that will receive uploads. In our case, it's the profile.avatar field.

The shape of `UploadsConfig` looks like this:
The shape of the config looks like this:

```
[prismaModel] : {
Expand Down Expand Up @@ -328,14 +332,14 @@ For each of the models you configured when you setup uploads (in `UploadConfig`)
So if you passed:

```ts
const uploadConfig: UploadsConfig = {
const uploadConfig = createUploadsConfig({
profile: {
fields: ['avatar'],
},
anotherModel: {
fields: ['document'],
},
}
})

const { saveFiles } = setupStorage(uploadConfig)

Expand Down Expand Up @@ -681,6 +685,50 @@ The output of `withDataUri` would be your profile object, with the upload fields
}
```

## Storage Adapters

Storage adapters are crucial for abstracting the underlying storage mechanism, allowing for flexibility in how files are managed. The BaseStorageAdapter defines a standard interface for all storage adapters, and looks like this:

```ts
export abstract class BaseStorageAdapter {
adapterOpts: AdapterOptions
constructor(adapterOpts: AdapterOptions) {
this.adapterOpts = adapterOpts
}

getAdapterOptions() {
return this.adapterOpts
}

generateFileNameWithExtension(
saveOpts: SaveOptionsOverride | undefined,
file: File
) {
/** We give you an easy way to generate file names **/
}

abstract save(
file: File,
saveOpts?: SaveOptionsOverride
): Promise<AdapterResult>

abstract remove(fileLocation: AdapterResult['location']): Promise<void>

abstract read(fileLocation: AdapterResult['location']): Promise<{
contents: Buffer | string
type: ReturnType<typeof mime.lookup>
}>
}
```

Types of Storage Adapters
MemoryStorage: This adapter stores files in memory, making it ideal for temporary storage needs or testing scenarios. It offers faster access times but does not persist data across application restarts.

We build in two storage adapters:

- [FileSystemStorage](https://github.com/redwoodjs/redwood/blob/main/packages/storage/src/adapters/FileSystemStorage/FileSystemStorage.ts) - This adapter interacts with the file system, enabling the storage of files on disk.
- [MemoryStorage](https://github.com/redwoodjs/redwood/blob/main/packages/storage/src/adapters/MemoryStorage/MemoryStorage.ts) - this adapter stores files in memory, making it ideal for temporary storage needs or testing scenarios. It offers faster access times but does not persist data across application restarts.

## Configuring the server further

Sometimes, you may need more control over how the Redwood API server behaves. This could include customizing the body limit for requests, redirects, or implementing additional logic - that's exactly what the [Server File](server-file.md) is for!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import path from 'node:path'

import { describe, it, expect } from 'vitest'

import { runTransform } from '../../../../lib/runTransform'

describe('Db codemod', () => {
it('Handles the default db case', async () => {
await matchTransformSnapshot('dbCodemod', 'defaultDb')
})

it('will throw an error if the db file has the old format', async () => {
const transformResult = await runTransform({
transformPath: path.join(__dirname, '../dbCodemod.ts'), // Use TS here!
targetPaths: [
path.join(__dirname, '../__testfixtures__/oldFormat.input.ts'),
],
})

expect(transformResult.error).toContain('ERR_OLD_FORMAT')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// See https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/constructor
// for options.

import { PrismaClient } from '@prisma/client'

import { emitLogLevels, handlePrismaLogging } from '@redwoodjs/api/logger'

import { logger } from './logger'

const prismaClient = new PrismaClient({
log: emitLogLevels(['info', 'warn', 'error']),
})

handlePrismaLogging({
db: prismaClient,
logger,
logLevels: ['info', 'warn', 'error'],
})

/**
* Global Prisma client extensions should be added here, as $extend
* returns a new instance.
* export const db = prismaClient.$extend(...)
* Add any .$on hooks before using $extend
*/
export const db = prismaClient
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// See https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/constructor
// for options.

import { PrismaClient } from '@prisma/client'

import { emitLogLevels, handlePrismaLogging } from '@redwoodjs/api/logger'

import { logger } from './logger'

import { storagePrismaExtension } from './uploads'

const prismaClient = new PrismaClient({
log: emitLogLevels(['info', 'warn', 'error']),
})

handlePrismaLogging({
db: prismaClient,
logger,
logLevels: ['info', 'warn', 'error'],
})

/**
* Global Prisma client extensions should be added here, as $extend
* returns a new instance.
* export const db = prismaClient.$extend(...)
* Add any .$on hooks before using $extend
*/
export const db = prismaClient.$extends(storagePrismaExtension)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// See https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/constructor
// for options.

import { PrismaClient } from '@prisma/client'

import { emitLogLevels, handlePrismaLogging } from '@redwoodjs/api/logger'

import { logger } from './logger'

export const db = new PrismaClient({
log: emitLogLevels(['info', 'warn', 'error']),
})

handlePrismaLogging({
db,
logger,
logLevels: ['info', 'warn', 'error'],
})
44 changes: 44 additions & 0 deletions packages/cli/src/commands/setup/uploads/dbCodemod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import j from 'jscodeshift'

module.exports = function transform(fileInfo: j.FileInfo) {
const root = j(fileInfo.source)

// Add the import statement for storagePrismaExtension
const imports = root.find(j.ImportDeclaration)

imports
.at(-1) // add it after the last one
.insertAfter(
j.importDeclaration(
[j.importSpecifier(j.identifier('storagePrismaExtension'))],
j.literal('./uploads'),
),
)

// Find the export statement for db and modify it
root
.find(j.VariableDeclaration, { declarations: [{ id: { name: 'db' } }] })
.forEach((path) => {
const dbDeclaration = path.node.declarations[0]

if (
j.VariableDeclarator.check(dbDeclaration) &&
j.NewExpression.check(dbDeclaration.init)
) {
throw new Error('RW_CODEMOD_ERR_OLD_FORMAT')
}

if (
j.VariableDeclarator.check(dbDeclaration) &&
j.Expression.check(dbDeclaration.init)
) {
const newInit = j.callExpression(
j.memberExpression(dbDeclaration.init, j.identifier('$extends')),
[j.identifier('storagePrismaExtension')],
)
dbDeclaration.init = newInit
}
})

return root.toSource()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { APIGatewayEvent, Context } from 'aws-lambda'

import type { SignatureValidationArgs } from '@redwoodjs/storage/UrlSigner'

import { urlSigner, fsStorage } from 'src/lib/uploads'

export const handler = async (event: APIGatewayEvent, _context: Context) => {
const fileToReturn = urlSigner.validateSignature(
event.queryStringParameters as SignatureValidationArgs
)

const { contents, type } = await fsStorage.read(fileToReturn)

return {
statusCode: 200,
headers: {
'Content-Type': type,
},
body: contents,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createUploadsConfig, setupStorage } from '@redwoodjs/storage'
import { FileSystemStorage } from '@redwoodjs/storage/FileSystemStorage'
import { UrlSigner } from '@redwoodjs/storage/UrlSigner'

const uploadsConfig = createUploadsConfig({
// Configure your fields here
// e.g. modelName: { fields: ['fieldWithUpload']}
})

export const fsStorage = new FileSystemStorage({
baseDir: './uploads',
})

export const urlSigner = new UrlSigner({
secret: process.env.UPLOADS_SECRET,
endpoint: '/signedUrl',
})

const { saveFiles, storagePrismaExtension } = setupStorage({
uploadsConfig,
storageAdapter: fsStorage,
urlSigner,
})

export { saveFiles, storagePrismaExtension }
25 changes: 25 additions & 0 deletions packages/cli/src/commands/setup/uploads/uploads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers'

export const command = 'uploads'

export const description =
'Setup uploads and storage. This will install the required packages and add the required initial configuration to your redwood app.'

export const builder = (yargs) => {
yargs.option('force', {
alias: 'f',
default: false,
description: 'Overwrite existing configuration',
type: 'boolean',
})
}

export const handler = async (options) => {
recordTelemetryAttributes({
command: 'setup uploads',
force: options.force,
skipExamples: options.skipExamples,
})
const { handler } = await import('./uploadsHandler.js')
return handler(options)
}
Loading

0 comments on commit eae1e98

Please sign in to comment.