Skip to content

Commit

Permalink
feat(mongodb): Add ObjectId resolvers and MongoDB option in the guide (
Browse files Browse the repository at this point in the history
  • Loading branch information
daffl authored Nov 5, 2022
1 parent 9bf5222 commit c5c1fba
Show file tree
Hide file tree
Showing 14 changed files with 423 additions and 67 deletions.
143 changes: 97 additions & 46 deletions docs/api/databases/mongodb.md

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 21 additions & 1 deletion docs/guides/basics/generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,36 @@ Since the generated application is using modern features like ES modules, the Fe

First, choose if you want to use JavaScript or TypeScript. When presented with the project name, just hit enter, or enter a name (no spaces). Next, write a short description for your application. Confirm the next questions with the default selection by pressing Enter. When asked about authentication methods, let's include GitHub as well so we can look at adding a "Log In with Github" button.

<DatabaseBlock global-id="sql">

<BlockQuote type="tip">

If you want to use **MongoDB** instead of SQLite (or another SQL database) for this quide, select it in the **Database** dropdown in the main menu.

</BlockQuote>

</DatabaseBlock>

Once you confirm the last prompt, the final selection should look similar to this:

<DatabaseBlock global-id="sql">

![feathers generate app prompts](./assets/generate-app.png)

<BlockQuote type="warning" label="Note">
<BlockQuote type="info" label="Note">

`SQLite` creates an SQL database in a file so we don't need to have a database server running. For any other selection, the database you choose has to be available at the connection string.

</BlockQuote>

</DatabaseBlock>

<DatabaseBlock global-id="mongodb">

![feathers generate app prompts](./assets/generate-app-mongodb.png)

</DatabaseBlock>

Sweet! We generated our first Feathers application in a new folder called `feathers-chat` so we need to go there.

```sh
Expand Down
183 changes: 183 additions & 0 deletions docs/guides/basics/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ First we need to update the `src/services/users/users.schema.js` file with the s

</LanguageBlock>

<DatabaseBlock global-id="sql">

```ts{1,16-17,36,47-57,70-74}
import crypto from 'crypto'
import { resolve } from '@feathersjs/schema'
Expand Down Expand Up @@ -127,6 +129,90 @@ export const userQueryResolver = resolve<UserQuery, HookContext>({
})
```

</DatabaseBlock>

<DatabaseBlock global-id="mongodb">

```ts{1,16-17,36,47-57,70-74}
import crypto from 'crypto'
import { resolve } from '@feathersjs/schema'
import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox'
import type { Static } from '@feathersjs/typebox'
import { passwordHash } from '@feathersjs/authentication-local'
import type { HookContext } from '../../declarations'
import { dataValidator, queryValidator } from '../../schemas/validators'
// Main data model schema
export const userSchema = Type.Object(
{
_id: Type.String(),
email: Type.String(),
password: Type.Optional(Type.String()),
githubId: Type.Optional(Type.Number()),
avatar: Type.Optional(Type.String())
},
{ $id: 'User', additionalProperties: false }
)
export type User = Static<typeof userSchema>
export const userResolver = resolve<User, HookContext>({
properties: {}
})
export const userExternalResolver = resolve<User, HookContext>({
properties: {
// The password should never be visible externally
password: async () => undefined
}
})
// Schema for the basic data model (e.g. creating new entries)
export const userDataSchema = Type.Pick(userSchema, ['email', 'password', 'githubId', 'avatar'], {
$id: 'UserData',
additionalProperties: false
})
export type UserData = Static<typeof userDataSchema>
export const userDataValidator = getDataValidator(userDataSchema, dataValidator)
export const userDataResolver = resolve<User, HookContext>({
properties: {
password: passwordHash({ strategy: 'local' }),
avatar: async (value, user) => {
// If the user passed an avatar image, use it
if (value !== undefined) {
return value
}
// Gravatar uses MD5 hashes from an email address to get the image
const hash = crypto.createHash('md5').update(user.email.toLowerCase()).digest('hex')
// Return the full avatar URL
return `https://s.gravatar.com/avatar/${hash}?s=60`
}
}
})
// Schema for allowed query properties
export const userQueryProperties = Type.Pick(userSchema, ['_id', 'email', 'githubId'])
export const userQuerySchema = querySyntax(userQueryProperties)
export type UserQuery = Static<typeof userQuerySchema>
export const userQueryValidator = getValidator(userQuerySchema, queryValidator)
export const userQueryResolver = resolve<UserQuery, HookContext>({
properties: {
// If there is a user (e.g. with authentication), they are only allowed to see their own data
_id: async (value, user, context) => {
// We want to be able to get a list of all users but
// only let a user modify their own data otherwise
if (context.params.user && context.method !== 'find') {
return context.params.user._id
}
return value
}
}
})
```

</DatabaseBlock>

## Handling messages

Next we can look at the messages service schema. We want to include the date when the message was created as `createdAt` and the id of the user who sent it as `userId`. When we get a message back, we also want to populate the `user` with the user data from `userId` so that we can show e.g. the user image and email.
Expand All @@ -142,6 +228,8 @@ Update the `src/services/messages/messages.schema.js` file like this:

</LanguageBlock>

<DatabaseBlock global-id="sql">

```ts{7,14-16,23-26,43-49,56,66-74}
import { resolve } from '@feathersjs/schema'
import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox'
Expand Down Expand Up @@ -221,8 +309,91 @@ export const messageQueryResolver = resolve<MessageQuery, HookContext>({
})
```

</DatabaseBlock>

<DatabaseBlock global-id="mongodb">

```ts{7,14-16,23-26,43-49,56,66-74}
import { resolve } from '@feathersjs/schema'
import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox'
import type { Static } from '@feathersjs/typebox'
import type { HookContext } from '../../declarations'
import { dataValidator, queryValidator } from '../../schemas/validators'
import { userSchema } from '../users/users.schema'
// Main data model schema
export const messageSchema = Type.Object(
{
_id: Type.String(),
text: Type.String(),
createdAt: Type.Number(),
userId: Type.String(),
user: Type.Ref(userSchema)
},
{ $id: 'Message', additionalProperties: false }
)
export type Message = Static<typeof messageSchema>
export const messageResolver = resolve<Message, HookContext>({
properties: {
user: async (_value, message, context) => {
// Associate the user that sent the message
return context.app.service('users').get(message.userId)
}
}
})
export const messageExternalResolver = resolve<Message, HookContext>({
properties: {}
})
// Schema for creating new entries
export const messageDataSchema = Type.Pick(messageSchema, ['text'], {
$id: 'MessageData',
additionalProperties: false
})
export type MessageData = Static<typeof messageDataSchema>
export const messageDataValidator = getDataValidator(messageDataSchema, dataValidator)
export const messageDataResolver = resolve<Message, HookContext>({
properties: {
userId: async (_value, _message, context) => {
// Associate the record with the id of the authenticated user
return context.params.user._id
},
createdAt: async () => {
return Date.now()
}
}
})
// Schema for allowed query properties
export const messageQueryProperties = Type.Pick(messageSchema, ['_id', 'text', 'createdAt', 'userId'], {
additionalProperties: false
})
export const messageQuerySchema = querySyntax(messageQueryProperties)
export type MessageQuery = Static<typeof messageQuerySchema>
export const messageQueryValidator = getValidator(messageQuerySchema, queryValidator)
export const messageQueryResolver = resolve<MessageQuery, HookContext>({
properties: {
userId: async (value, user, context) => {
// We want to be able to get a list of all messages but
// only let a user access their own messages otherwise
if (context.params.user && context.method !== 'find') {
return context.params.user._id
}
return value
}
}
})
```

</DatabaseBlock>

## Creating a migration

<DatabaseBlock global-id="sql">

Now that our schemas and resolvers have everything we need, we also have to update the database with those changes. For SQL databases this is done with migrations. Migrations are a best practise for SQL databases to roll out and undo changes to the data model. Every change we make in a schema will need its corresponding migration step.

<BlockQuote type="warning">
Expand Down Expand Up @@ -283,6 +454,18 @@ We can run the migrations on the current database with
npm run migrate
```

</DatabaseBlock>

<DatabaseBlock global-id="mongodb">

<BlockQuote type="tip">

For MongoDB no migrations are necessary.

</BlockQuote>

</DatabaseBlock>

## What's next?

In this chapter we learned about schemas and implemented all the things we need for our chat application. In the next chapter we will learn about [authentication](./authentication.md) and add a "Login with GitHub".
10 changes: 10 additions & 0 deletions docs/guides/basics/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,18 @@ npx feathers generate service

The name for our service is `message` (this is used for variable names etc.) and for the path we use `messages`. Anything else we can confirm with the default:

<DatabaseBlock global-id="sql">

![feathers generate service prompts](./assets/generate-service.png)

</DatabaseBlock>

<DatabaseBlock global-id="mongodb">

![feathers generate service prompts](./assets/generate-service-mongodb.png)

</DatabaseBlock>

This is it, we now have a database backed messages service with authentication enabled.

## What's next?
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/frontend/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ const showChat = async () => {
}
```

- `showLogin(error)` will either show the content of loginHTML or, if the login page is already showing, add an error message. This will happen when you try to log in with invalid credentials or # with a user that already exists.
- `showChat()` does several things. First, we add the static chatHTML to the page. Then we get the latest 25 messages from the messages Feathers service (this is the same as the `/messages` endpoint of our chat API) using the Feathers query syntax. Since the list will come back with the newest message first, we need to reverse the data. Then we add each message by calling our `addMessage` function so that it looks like a chat app should — with old messages getting older as you scroll up. After that we get a list of all registered users to show them in the sidebar by calling addUser.
- `showLogin(error)` will either show the content of loginTemplate or, if the login page is already showing, add an error message. This will happen when you try to log in with invalid credentials or # with a user that already exists.
- `showChat()` does several things. First, we add the static chatTemplate to the page. Then we get the latest 25 messages from the messages Feathers service (this is the same as the `/messages` endpoint of our chat API) using the Feathers query syntax. Since the list will come back with the newest message first, we need to reverse the data. Then we add each message by calling our `addMessage` function so that it looks like a chat app should — with old messages getting older as you scroll up. After that we get a list of all registered users to show them in the sidebar by calling addUser.

## Login and #

Expand Down Expand Up @@ -313,7 +313,7 @@ addEventListener('#login', 'click', async () => {
addEventListener('#logout', 'click', async () => {
await client.logout()

document.getElementById('app').innerHTML = loginHTML
document.getElementById('app').innerHTML = loginTemplate()
})

// "Send" message form submission handler
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/connection/templates/mongodb.tpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ declare module './declarations' {
export const mongodb = (app: Application) => {
const connection = app.get('mongodb') as string
const database = new URL("mongodb://localhost").pathname.substring(1)
const database = new URL(connection).pathname.substring(1)
const mongoClient = MongoClient.connect(connection)
.then(client => client.db(database))
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/generators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('@feathersjs/cli', () => {

before(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), name + '-'))
console.log(cwd)
console.log(`\nGenerating test application to\n${cwd}\n\n`)
context = await generateApp(
getContext<AppGeneratorContext>(
{
Expand Down
16 changes: 9 additions & 7 deletions packages/mongodb/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class MongoDbAdapter<
return id
}

filterQuery(id: NullableId, params: P) {
filterQuery(id: NullableId | ObjectId, params: P) {
const { $select, $sort, $limit, $skip, ...query } = (params.query || {}) as AdapterQuery

if (id !== null) {
Expand Down Expand Up @@ -164,11 +164,11 @@ export class MongoDbAdapter<
return select
}

async $findOrGet(id: NullableId, params: P) {
async $findOrGet(id: NullableId | ObjectId, params: P) {
return id === null ? await this.$find(params) : await this.$get(id, params)
}

normalizeId(id: NullableId, data: Partial<D>): Partial<D> {
normalizeId(id: NullableId | ObjectId, data: Partial<D>): Partial<D> {
if (this.id === '_id') {
// Default Mongo IDs cannot be updated. The Mongo library handles
// this automatically.
Expand All @@ -184,7 +184,7 @@ export class MongoDbAdapter<
return data
}

async $get(id: Id, params: P = {} as P): Promise<T> {
async $get(id: Id | ObjectId, params: P = {} as P): Promise<T> {
const {
query,
filters: { $select }
Expand Down Expand Up @@ -286,8 +286,9 @@ export class MongoDbAdapter<

async $patch(id: null, data: Partial<D>, params?: P): Promise<T[]>
async $patch(id: Id, data: Partial<D>, params?: P): Promise<T>
async $patch(id: ObjectId, data: Partial<D>, params?: P): Promise<T>
async $patch(id: NullableId, data: Partial<D>, _params?: P): Promise<T | T[]>
async $patch(id: NullableId, _data: Partial<D>, params: P = {} as P): Promise<T | T[]> {
async $patch(id: NullableId | ObjectId, _data: Partial<D>, params: P = {} as P): Promise<T | T[]> {
const data = this.normalizeId(id, _data)
const model = await this.getModel(params)
const {
Expand Down Expand Up @@ -333,7 +334,7 @@ export class MongoDbAdapter<
return this.$findOrGet(id, findParams).catch(errorHandler)
}

async $update(id: Id, data: D, params: P = {} as P): Promise<T> {
async $update(id: Id | ObjectId, data: D, params: P = {} as P): Promise<T> {
const model = await this.getModel(params)
const { query } = this.filterQuery(id, params)
const replaceOptions = { ...params.mongodb }
Expand All @@ -345,8 +346,9 @@ export class MongoDbAdapter<

async $remove(id: null, params?: P): Promise<T[]>
async $remove(id: Id, params?: P): Promise<T>
async $remove(id: ObjectId, params?: P): Promise<T>
async $remove(id: NullableId, _params?: P): Promise<T | T[]>
async $remove(id: NullableId, params: P = {} as P): Promise<T | T[]> {
async $remove(id: NullableId | ObjectId, params: P = {} as P): Promise<T | T[]> {
const model = await this.getModel(params)
const {
query,
Expand Down
Loading

0 comments on commit c5c1fba

Please # to comment.