diff --git a/docs/api/databases/mongodb.md b/docs/api/databases/mongodb.md index 903b7be0d6..bc96707d3d 100644 --- a/docs/api/databases/mongodb.md +++ b/docs/api/databases/mongodb.md @@ -13,7 +13,6 @@ outline: deep Support for MongoDB is provided in Feathers via the `@feathersjs/mongodb` database adapter which uses the [MongoDB Client for Node.js](https://www.npmjs.com/package/mongodb). The adapter uses the [MongoDB Aggregation Framework](https://www.mongodb.com/docs/manual/aggregation/), internally, and enables using Feathers' friendly syntax with the full power of [aggregation operators](https://www.mongodb.com/docs/manual/meta/aggregation-quick-reference/). The adapter automatically uses the [MongoDB Query API](https://www.mongodb.com/docs/drivers/node/current/quick-reference/) when you need features like [Collation](https://www.mongodb.com/docs/drivers/node/current/fundamentals/collations/). - ```bash $ npm install --save @feathersjs/mongodb ``` @@ -26,14 +25,14 @@ The MongoDB adapter implements the [common database adapter API](./common) and [ ## Setup -There are two typical setup steps for using `@feathersjs/mongodb` in an application: +There are two typical setup steps for using `@feathersjs/mongodb` in an application: -- connect to the database and +- connect to the database and - setup schemas for types and validation. ### Connect to the Database -Before using `@feathersjs/mongodb`, you'll need to create a connection to the database. This example connects to a MongoDB database similar to how the CLI-generated app connects. It uses `app.get('mongodb')` to read the connection string from `@feathersjs/configuration`. The connection string would be something similar to `mongodb://localhost:27017/my-app-dev` for local development or one provided by your database host. +Before using `@feathersjs/mongodb`, you'll need to create a connection to the database. This example connects to a MongoDB database similar to how the CLI-generated app connects. It uses `app.get('mongodb')` to read the connection string from `@feathersjs/configuration`. The connection string would be something similar to `mongodb://localhost:27017/my-app-dev` for local development or one provided by your database host. Once the connection attempt has been started, the code uses `app.set('monodbClient', mongoClient)` to store the connection promise back into the config, which allows it to be looked up when initializing individual services. @@ -45,8 +44,7 @@ import type { Application } from './declarations' export const mongodb = (app: Application) => { const connection = app.get('mongodb') const database = new URL(connection).pathname.substring(1) - const mongoClient = MongoClient.connect(connection) - .then(client => client.db(database)) + const mongoClient = MongoClient.connect(connection).then((client) => client.db(database)) app.set('mongodbClient', mongoClient) } @@ -69,27 +67,30 @@ import type { Static } from '@feathersjs/typebox' export const messagesSchema = Type.Object( { _id: Type.String(), - text: Type.String(), + text: Type.String() }, - { $id: 'Messages', additionalProperties: false }, + { $id: 'Messages', additionalProperties: false } ) export type Messages = Static // Schema for creating new entries -export const messagesDataSchema = Type.Pick(messagesSchema, ['name'], - { $id: 'MessagesData', additionalProperties: false }, -) +export const messagesDataSchema = Type.Pick(messagesSchema, ['name'], { + $id: 'MessagesData', + additionalProperties: false +}) export type MessagesData = Static // Schema for allowed query properties -export const messagesQueryProperties = Type.Pick(messagesSchema, ['_id', 'name'], { additionalProperties: false }) +export const messagesQueryProperties = Type.Pick(messagesSchema, ['_id', 'name'], { + additionalProperties: false +}) export const messagesQuerySchema = querySyntax(messagesQueryProperties) export type MessagesQuery = Static ``` ### Schemas vs MongoDB Validation -In Feathers v5 (Dove) we added support for Feathers Schema, which performs validation and provides TypeScript types. Recent versions of MongoDB include support for JSON Schema validation at the database server. Most applications will benefit from using Feathers Schema for the following reasons. +In Feathers v5 (Dove) we added support for Feathers Schema, which performs validation and provides TypeScript types. Recent versions of MongoDB include support for JSON Schema validation at the database server. Most applications will benefit from using Feathers Schema for the following reasons. - Feathers Schema's TypeBox integration makes JSON Schema so much easier to read and write. - You get TypeScript types for free once you've defined your validation rules, using `TypeBox` or `json-schema-to-ts` @@ -116,17 +117,20 @@ import type { Messages, MessagesData, MessagesQuery } from './messages.schema' export interface MessagesParams extends MongoDBAdapterParams {} -export class MessagesService - extends MongoDBService {} +export class MessagesService extends MongoDBService< + Messages, + MessagesData, + ServiceParams +> {} export const messages = (app: Application) => { const options: MongoDBAdapterOptions = { paginate: app.get('paginate'), - Model: app.get('mongodbClient').then((db) => db.collection('messages')), + Model: app.get('mongodbClient').then((db) => db.collection('messages')) } app.use('messages', new MessagesService(options), { methods: ['find', 'get', 'create', 'update', 'patch', 'remove'], - events: [], + events: [] }) } ``` @@ -136,14 +140,14 @@ Here's an overview of the `options` object: **Options:** - `Model {Promise}` (**required**) - The MongoDB collection instance -- `id {string}` (*optional*, default: `'_id'`) - The name of the id field property. By design, MongoDB will always add an `_id` property. -- `disableObjectify {boolean}` (*optional*, default `false`) - This will disable the objectify of the id field if you want to use normal strings -- `events {string[]}` (*optional*) - A list of [custom service events](/api/events.html#custom-events) sent by this service -- `paginate {Object}` (*optional*) - A [pagination object](/api/databases/common.html#pagination) containing a `default` and `max` page size -- `filters {Object}` (*optional*) - An object of additional filter parameters to allow (e..g `{ $customQueryOperator: true }`). See [Filters](/api/databases/querying.md#filters) -- `operators {string[]}` (*optional*) - A list of additional query parameters to allow (e..g `[ '$regex', '$geoNear' ]`) See [Operators](/api/databases/querying.md#operators) -- `multi {string[]|true}` (*optional*) - Allow `create` with arrays and `update` and `remove` with `id` `null` to change multiple items. Can be `true` for all methods or an array of allowed methods (e.g. `[ 'remove', 'create' ]`) -- `useEstimatedDocumentCount {boolean}` (*optional*, default `false`) - If `true` document counting will rely on `estimatedDocumentCount` instead of `countDocuments` +- `id {string}` (_optional_, default: `'_id'`) - The name of the id field property. By design, MongoDB will always add an `_id` property. +- `disableObjectify {boolean}` (_optional_, default `false`) - This will disable the objectify of the id field if you want to use normal strings +- `events {string[]}` (_optional_) - A list of [custom service events](/api/events.html#custom-events) sent by this service +- `paginate {Object}` (_optional_) - A [pagination object](/api/databases/common.html#pagination) containing a `default` and `max` page size +- `filters {Object}` (_optional_) - An object of additional filter parameters to allow (e..g `{ $customQueryOperator: true }`). See [Filters](/api/databases/querying.md#filters) +- `operators {string[]}` (_optional_) - A list of additional query parameters to allow (e..g `[ '$regex', '$geoNear' ]`) See [Operators](/api/databases/querying.md#operators) +- `multi {string[]|true}` (_optional_) - Allow `create` with arrays and `update` and `remove` with `id` `null` to change multiple items. Can be `true` for all methods or an array of allowed methods (e.g. `[ 'remove', 'create' ]`) +- `useEstimatedDocumentCount {boolean}` (_optional_, default `false`) - If `true` document counting will rely on `estimatedDocumentCount` instead of `countDocuments` ### `aggregateRaw(params)` @@ -151,7 +155,7 @@ The `find` method has been split into separate utilities for converting params i ### `findRaw(params)` -The `find` method has been split into separate utilities for converting params into different types of MongoDB requests. When `params.mongodb` is used, the `findRaw` method is used to retrieve data using `params.mongodb` as the `FindOptions` object. This method returns a raw MongoDB Cursor object, which can be used to perform custom pagination or in custom server scripts, if desired. +The `find` method has been split into separate utilities for converting params into different types of MongoDB requests. When `params.mongodb` is used, the `findRaw` method is used to retrieve data using `params.mongodb` as the `FindOptions` object. This method returns a raw MongoDB Cursor object, which can be used to perform custom pagination or in custom server scripts, if desired. ### `makeFeathersPipeline(params)` @@ -175,31 +179,31 @@ The adapter will automatically switch to use the MongoClient's`collection.find` In Feathers v5 Dove, we added support for the full power of MongoDB's Aggregation Framework and blends it seamlessly with the familiar Feathers Query syntax. All `find` queries now use the Aggregation Framework, by default. -The Aggregation Framework is accessed through the mongoClient's `collection.aggregate` method, which accepts an array of "stages". Each stage contains an operator which describes an operation to apply to the previous step's data. Each stage applies the operation to the results of the previous step. It’s now possible to perform any of the [Aggregation Stages](https://www.mongodb.com/docs/upcoming/reference/operator/aggregation-pipeline/) like `$lookup` and `$unwind`, integration with the normal Feathers queries. +The Aggregation Framework is accessed through the mongoClient's `collection.aggregate` method, which accepts an array of "stages". Each stage contains an operator which describes an operation to apply to the previous step's data. Each stage applies the operation to the results of the previous step. It’s now possible to perform any of the [Aggregation Stages](https://www.mongodb.com/docs/upcoming/reference/operator/aggregation-pipeline/) like `$lookup` and `$unwind`, integration with the normal Feathers queries. Here's how it works with the operators that match the Feathers Query syntax. Let's convert the following Feathers query: ```ts const query = { - text: { $regex: 'feathersjs', $options: 'igm' }, + text: { $regex: 'feathersjs', $options: 'igm' }, $sort: { createdAt: -1 }, $skip: 0, - $limit: 10, + $limit: 10 } ``` The above query looks like this when converted to aggregation pipeline stages: ```ts -[ +;[ // returns the set of records containing the word "feathersjs" - { $match: { text: { $regex: 'feathersjs', $options: 'igm' } } }, + { $match: { text: { $regex: 'feathersjs', $options: 'igm' } } }, // Sorts the results of the previous step by newest messages, first. { $sort: { createdAt: -1 } }, // Skips the first 20 records of the previous step { $skip: 20 }, // returns the next 10 records - { $limit: 10 }, + { $limit: 10 } ] ``` @@ -229,7 +233,7 @@ const result = await app.service('messages').find({ In the example, above, the `query` is added to the pipeline, first. Then additional stages are added in the `pipeline` option: -- The `$lookup` stage creates an array called `user` which contains any matches in `message.userId`, so if `userId` were an array of ids, any matches would be in the `users` array. However, in this example, the `userId` is a single id, so... +- The `$lookup` stage creates an array called `user` which contains any matches in `message.userId`, so if `userId` were an array of ids, any matches would be in the `users` array. However, in this example, the `userId` is a single id, so... - The `$unwind` stage turns the array into a single `user` object. The above is like doing a join, but without the data transforming overhead like you'd get with an SQL JOIN. If you have properly applied index to your MongoDB collections, the operation will typically execute extremely fast for a reasonable amount of data. @@ -249,9 +253,9 @@ The previous section showed how to append stages to a query using `params.pipeli ### Example: Proxy Permissions -Imagine a scenario where you want to query the `pages` a user can edit by referencing a `permissions` collection to find out which pages the user can actually edit. Each record in the `permissions` record has a `userId` and a `pageId`. So we need to find and return only the pages to which the user has access by calling `GET /pages` from the client. +Imagine a scenario where you want to query the `pages` a user can edit by referencing a `permissions` collection to find out which pages the user can actually edit. Each record in the `permissions` record has a `userId` and a `pageId`. So we need to find and return only the pages to which the user has access by calling `GET /pages` from the client. -We could put the following query in a hook to pull the correct `pages` from the database in a single query THROUGH the permissions collection. Remember, the request is coming in on the `pages` service, but we're going to query for pages `through` the permissions collection. Assume we've already authenticated the user, so the user will be found at `context.params.user`. +We could put the following query in a hook to pull the correct `pages` from the database in a single query THROUGH the permissions collection. Remember, the request is coming in on the `pages` service, but we're going to query for pages `through` the permissions collection. Assume we've already authenticated the user, so the user will be found at `context.params.user`. ```ts // Assume this query on the client @@ -262,8 +266,8 @@ const result = await app.service('permissions').find({ query: {}, pipeline: [ // query all permissions records which apply to the current user - { - $match: { userId: context.params.user._id } + { + $match: { userId: context.params.user._id } }, // populate the pageId onto each `permission` record, as an array containing one page { @@ -271,8 +275,8 @@ const result = await app.service('permissions').find({ from: 'pages', localField: 'pageId', foreignField: '_id', - as: 'page', - }, + as: 'page' + } }, // convert the `page` array into an object, so now we have an array of permissions with permission.page on each. { @@ -292,23 +296,23 @@ const result = await app.service('permissions').find({ // now the query will apply to the pages, since we made the pages top level in the previous step. { $feathers: {} - }, + } ], paginate: false }) ``` -Notice the `$feathers` stage in the above example. It will apply the query to that stage in the pipeline, which allows the query to apply to pages even though we had to make the query through the `permissions` service. +Notice the `$feathers` stage in the above example. It will apply the query to that stage in the pipeline, which allows the query to apply to pages even though we had to make the query through the `permissions` service. If we were to express the above query with JavaScript, the final result would the same as with the following example: ```ts // perform a db query to get the permissions -const permissions = await context.app.service('permissions').find({ - query: { +const permissions = await context.app.service('permissions').find({ + query: { userId: context.params.user._id }, - paginate: false, + paginate: false }) // make a list of pageIds const pageIds = permissions.map((permission) => permission.pageId) @@ -335,9 +339,9 @@ const pagesWithPermissionId = pages.map((page) => { // It might require another database query ``` -Both examples look a bit complex, but te one using aggregation stages will be much quicker because all stages run in the database server. It will also be quicker because it all happens in a single database query! +Both examples look a bit complex, but te one using aggregation stages will be much quicker because all stages run in the database server. It will also be quicker because it all happens in a single database query! -One more obstacle for using JavaScript this way is that if the user's query changed (from the front end), we would likely be required to edit multiple different parts of the JS logic in order to correctly display results. With the pipeline example, above, the query is very cleanly applied. +One more obstacle for using JavaScript this way is that if the user's query changed (from the front end), we would likely be required to edit multiple different parts of the JS logic in order to correctly display results. With the pipeline example, above, the query is very cleanly applied. ## Transactions @@ -583,3 +587,50 @@ You'll see an error like `Error: unknown format "date-time" ignored in schema at - You're attempting to use a formatter not built into AJV. - You fail to [Pass the Custom AJV Instance to every `schema`](#pass-the-custom-ajv-instance-to-schema). If you're using a custom AJV instance, be sure to provide it to **every** place where you call `schema()`. + +## Search + +## ObjectId resolvers + +MongoDB uses object ids as primary keys and for references to other documents. To a client they are represented as strings and to convert between strings and object ids, the following [property resolver](../schema/resolvers.md) helpers can be used. + +### resolveObjectId + +`resolveObjectId` resolves a property as an object id. It can be used as a direct property resolver or called with the original value. + +```ts +import { resolveObjectId } from '@feathersjs/mongodb' + +export const messageDataResolver = resolve({ + properties: { + userId: resolveObjectId + } +}) + +export const messageDataResolver = resolve({ + properties: { + userId: async (value, _message, context) => { + // If the user is an admin, allow them to create messages for other users + if (context.params.user.isAdmin && value !== undefined) { + return resolveObjectId(value) + } + // Otherwise associate the record with the id of the authenticated user + return context.params.user._id + } + } +}) +``` + +### resolveQueryObjectId + +`resolveQueryObjectId` allows to query for object ids. It supports conversion from a string to an object id as well as conversion for values from the [$in, $nin and $ne query syntax](./querying.md). + +```ts +import { resolveQueryObjectId } from '@feathersjs/mongodb' + +export const messageQueryResolver = resolve({ + properties: { + userId: resolveQueryObjectId + } +}) +``` diff --git a/docs/guides/basics/assets/generate-app-mongodb.png b/docs/guides/basics/assets/generate-app-mongodb.png new file mode 100644 index 0000000000..3ffdfbc634 Binary files /dev/null and b/docs/guides/basics/assets/generate-app-mongodb.png differ diff --git a/docs/guides/basics/assets/generate-service-mongodb.png b/docs/guides/basics/assets/generate-service-mongodb.png new file mode 100644 index 0000000000..2a7010adec Binary files /dev/null and b/docs/guides/basics/assets/generate-service-mongodb.png differ diff --git a/docs/guides/basics/generator.md b/docs/guides/basics/generator.md index 1345bff42d..11a945b3b5 100644 --- a/docs/guides/basics/generator.md +++ b/docs/guides/basics/generator.md @@ -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. + + +
+ +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. + +
+ +
+ Once you confirm the last prompt, the final selection should look similar to this: + + ![feathers generate app prompts](./assets/generate-app.png) -
+
`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.
+ + + + +![feathers generate app prompts](./assets/generate-app-mongodb.png) + + + Sweet! We generated our first Feathers application in a new folder called `feathers-chat` so we need to go there. ```sh diff --git a/docs/guides/basics/schemas.md b/docs/guides/basics/schemas.md index df677a1fc7..cd3a26f60f 100644 --- a/docs/guides/basics/schemas.md +++ b/docs/guides/basics/schemas.md @@ -45,6 +45,8 @@ First we need to update the `src/services/users/users.schema.js` file with the s + + ```ts{1,16-17,36,47-57,70-74} import crypto from 'crypto' import { resolve } from '@feathersjs/schema' @@ -127,6 +129,90 @@ export const userQueryResolver = resolve({ }) ``` + + + + +```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 +export const userResolver = resolve({ + properties: {} +}) + +export const userExternalResolver = resolve({ + 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 +export const userDataValidator = getDataValidator(userDataSchema, dataValidator) +export const userDataResolver = resolve({ + 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 +export const userQueryValidator = getValidator(userQuerySchema, queryValidator) +export const userQueryResolver = resolve({ + 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 + } + } +}) +``` + + + ## 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. @@ -142,6 +228,8 @@ Update the `src/services/messages/messages.schema.js` file like this: + + ```ts{7,14-16,23-26,43-49,56,66-74} import { resolve } from '@feathersjs/schema' import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox' @@ -221,8 +309,91 @@ export const messageQueryResolver = resolve({ }) ``` + + + + +```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 +export const messageResolver = resolve({ + 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({ + properties: {} +}) + +// Schema for creating new entries +export const messageDataSchema = Type.Pick(messageSchema, ['text'], { + $id: 'MessageData', + additionalProperties: false +}) +export type MessageData = Static +export const messageDataValidator = getDataValidator(messageDataSchema, dataValidator) +export const messageDataResolver = resolve({ + 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 +export const messageQueryValidator = getValidator(messageQuerySchema, queryValidator) +export const messageQueryResolver = resolve({ + 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 + } + } +}) +``` + + + ## Creating a migration + + 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.
@@ -283,6 +454,18 @@ We can run the migrations on the current database with npm run migrate ``` + + + + +
+ +For MongoDB no migrations are necessary. + +
+ +
+ ## 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". diff --git a/docs/guides/basics/services.md b/docs/guides/basics/services.md index 0717667e34..71dcb3b24e 100644 --- a/docs/guides/basics/services.md +++ b/docs/guides/basics/services.md @@ -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: + + ![feathers generate service prompts](./assets/generate-service.png) + + + + +![feathers generate service prompts](./assets/generate-service-mongodb.png) + + + This is it, we now have a database backed messages service with authentication enabled. ## What's next? diff --git a/docs/guides/frontend/javascript.md b/docs/guides/frontend/javascript.md index affc7c972b..4ba608b185 100644 --- a/docs/guides/frontend/javascript.md +++ b/docs/guides/frontend/javascript.md @@ -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 sign up 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 sign up 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 signup @@ -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 diff --git a/packages/cli/src/connection/templates/mongodb.tpl.ts b/packages/cli/src/connection/templates/mongodb.tpl.ts index 6e3dae2349..203eba6a32 100644 --- a/packages/cli/src/connection/templates/mongodb.tpl.ts +++ b/packages/cli/src/connection/templates/mongodb.tpl.ts @@ -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)) diff --git a/packages/cli/test/generators.test.ts b/packages/cli/test/generators.test.ts index 7a9e7b84fb..5280e774b1 100644 --- a/packages/cli/test/generators.test.ts +++ b/packages/cli/test/generators.test.ts @@ -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( { diff --git a/packages/mongodb/src/adapter.ts b/packages/mongodb/src/adapter.ts index 5909dd9150..35066c75fb 100644 --- a/packages/mongodb/src/adapter.ts +++ b/packages/mongodb/src/adapter.ts @@ -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) { @@ -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): Partial { + normalizeId(id: NullableId | ObjectId, data: Partial): Partial { if (this.id === '_id') { // Default Mongo IDs cannot be updated. The Mongo library handles // this automatically. @@ -184,7 +184,7 @@ export class MongoDbAdapter< return data } - async $get(id: Id, params: P = {} as P): Promise { + async $get(id: Id | ObjectId, params: P = {} as P): Promise { const { query, filters: { $select } @@ -286,8 +286,9 @@ export class MongoDbAdapter< async $patch(id: null, data: Partial, params?: P): Promise async $patch(id: Id, data: Partial, params?: P): Promise + async $patch(id: ObjectId, data: Partial, params?: P): Promise async $patch(id: NullableId, data: Partial, _params?: P): Promise - async $patch(id: NullableId, _data: Partial, params: P = {} as P): Promise { + async $patch(id: NullableId | ObjectId, _data: Partial, params: P = {} as P): Promise { const data = this.normalizeId(id, _data) const model = await this.getModel(params) const { @@ -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 { + async $update(id: Id | ObjectId, data: D, params: P = {} as P): Promise { const model = await this.getModel(params) const { query } = this.filterQuery(id, params) const replaceOptions = { ...params.mongodb } @@ -345,8 +346,9 @@ export class MongoDbAdapter< async $remove(id: null, params?: P): Promise async $remove(id: Id, params?: P): Promise + async $remove(id: ObjectId, params?: P): Promise async $remove(id: NullableId, _params?: P): Promise - async $remove(id: NullableId, params: P = {} as P): Promise { + async $remove(id: NullableId | ObjectId, params: P = {} as P): Promise { const model = await this.getModel(params) const { query, diff --git a/packages/mongodb/src/index.ts b/packages/mongodb/src/index.ts index b039d8992f..f1e10d0082 100644 --- a/packages/mongodb/src/index.ts +++ b/packages/mongodb/src/index.ts @@ -1,9 +1,11 @@ import { PaginationOptions } from '@feathersjs/adapter-commons' import { Paginated, ServiceMethods, Id, NullableId, Params } from '@feathersjs/feathers' +import { ObjectId } from 'mongodb' import { MongoDbAdapter, MongoDBAdapterParams } from './adapter' export * from './adapter' export * from './error-handler' +export * from './resolvers' export class MongoDBService, P extends Params = MongoDBAdapterParams> extends MongoDbAdapter @@ -16,8 +18,10 @@ export class MongoDBService, P extends Params = Mon return this._find(params) as any } - async get(id: Id, params?: P): Promise { - return this._get(id, params) + async get(id: ObjectId, params?: P): Promise + async get(id: Id, params?: P): Promise + async get(id: Id | ObjectId, params?: P): Promise { + return this._get(id as Id, params) } async create(data: D, params?: P): Promise @@ -26,19 +30,23 @@ export class MongoDBService, P extends Params = Mon return this._create(data, params) } - async update(id: Id, data: D, params?: P): Promise { - return this._update(id, data, params) + async update(id: Id, data: D, params?: P): Promise + async update(id: ObjectId, data: D, params?: P): Promise + async update(id: Id | ObjectId, data: D, params?: P): Promise { + return this._update(id as Id, data, params) } + async patch(id: ObjectId, data: Partial, params?: P): Promise async patch(id: Id, data: Partial, params?: P): Promise async patch(id: null, data: Partial, params?: P): Promise - async patch(id: NullableId, data: Partial, params?: P): Promise { - return this._patch(id, data, params) + async patch(id: NullableId | ObjectId, data: Partial, params?: P): Promise { + return this._patch(id as NullableId, data, params) } async remove(id: Id, params?: P): Promise + async remove(id: ObjectId, params?: P): Promise async remove(id: null, params?: P): Promise - async remove(id: NullableId, params?: P): Promise { - return this._remove(id, params) + async remove(id: NullableId | ObjectId, params?: P): Promise { + return this._remove(id as NullableId, params) } } diff --git a/packages/mongodb/src/resolvers.ts b/packages/mongodb/src/resolvers.ts new file mode 100644 index 0000000000..fb3604e9e5 --- /dev/null +++ b/packages/mongodb/src/resolvers.ts @@ -0,0 +1,41 @@ +import { ObjectId } from 'mongodb' + +export type ObjectIdParam = string | number | ObjectId + +export type IdQueryObject = { + $in?: T[] + $nin?: T[] + $ne?: T +} + +const toObjectId = (value: ObjectIdParam) => new ObjectId(value) + +export async function resolveObjectId(value: ObjectIdParam) { + return toObjectId(value) +} + +export async function resolveQueryObjectId( + value: IdQueryObject +): Promise> +export async function resolveQueryObjectId(value: ObjectIdParam): Promise +export async function resolveQueryObjectId(value: ObjectIdParam | IdQueryObject) { + if (typeof value === 'string' || typeof value === 'number' || value instanceof ObjectId) { + return toObjectId(value) + } + + const convertedObject: IdQueryObject = {} + + if (Array.isArray(value.$in)) { + convertedObject.$in = value.$in.map(toObjectId) + } + + if (Array.isArray(value.$nin)) { + convertedObject.$nin = value.$nin.map(toObjectId) + } + + if (value.$ne !== undefined) { + convertedObject.$ne = toObjectId(value.$ne) + } + + return convertedObject +} diff --git a/packages/mongodb/test/index.test.ts b/packages/mongodb/test/index.test.ts index b451a17d6e..6b80a084a8 100644 --- a/packages/mongodb/test/index.test.ts +++ b/packages/mongodb/test/index.test.ts @@ -173,6 +173,20 @@ describe('Feathers MongoDB Service', () => { }) }) + describe('works with ObjectIds', () => { + it('can call methods with ObjectId instance', async () => { + const person = await app.service('people').create({ + name: 'David' + }) + + const withId = await app.service('people').get(new ObjectId(person._id.toString())) + + assert.strictEqual(withId.name, 'David') + + await app.service('people').remove(new ObjectId(person._id.toString())) + }) + }) + describe('Special collation param', () => { let peopleService: MongoDBService let people: Person[] diff --git a/packages/mongodb/test/resolvers.test.ts b/packages/mongodb/test/resolvers.test.ts new file mode 100644 index 0000000000..b6d0cffc54 --- /dev/null +++ b/packages/mongodb/test/resolvers.test.ts @@ -0,0 +1,27 @@ +import assert from 'assert' +import { ObjectId } from 'mongodb' +import { resolveObjectId, resolveQueryObjectId } from '../src' + +describe('ObjectId resolvers', () => { + it('resolveObjectId', async () => { + const oid = await resolveObjectId('5f9e3c1b9b9b9b9b9b9b9b9b') + + assert.ok(oid instanceof ObjectId) + }) + + it('resolveQueryObjectId', async () => { + const oid = await resolveQueryObjectId('5f9e3c1b9b9b9b9b9b9b9b9b') + + assert.ok(oid instanceof ObjectId) + }) + + it('resolveQueryObjectId with object', async () => { + const oids = await resolveQueryObjectId({ + $in: ['5f9e3c1b9b9b9b9b9b9b9b9b'], + $ne: '5f9e3c1b9b9b9b9b9b9b9b9a' + }) + + assert.ok(oids.$in && oids.$in[0] instanceof ObjectId) + assert.ok(oids.$ne instanceof ObjectId) + }) +})