Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(schema): Allow regular functions in resolvers #3487

Merged
merged 1 commit into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 23 additions & 28 deletions docs/api/schema/resolvers.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
outline: deep
---

# Resolvers

Resolvers dynamically resolve individual properties based on a context, in a Feathers application usually the [hook context](../hooks.md#hook-context).
Expand Down Expand Up @@ -38,23 +39,23 @@ type Message = {
}

class MyContext {
async getUser(id) {
getUser(id) {
return {
id,
name: 'David'
}
}

async getLikes(messageId) {
getLikes(messageId) {
return 10
}
}

const messageResolver = resolve<Message, MyContext>({
likes: async (value, message, context) => {
likes: (value, message, context) => {
return context.getLikes(message.id)
},
user: async (value, message, context) => {
user: (value, message, context) => {
return context.getUser(message.userId)
}
})
Expand All @@ -71,7 +72,7 @@ const resolvedMessage = await messageResolver.resolve(

## Property resolvers

Property resolvers are a map of property names to resolver functions. A resolver function is an `async` function that resolves a property on a data object. If it returns `undefined` the property will not be included. It gets passed the following parameters:
Property resolvers are a map of property names to resolver functions. A resolver function is an `async` or regular function that resolves a property on a data object. If it returns `undefined` the property will not be included. It gets passed the following parameters:

- `value` - The current value which can also be `undefined`
- `data` - The initial data object
Expand All @@ -85,7 +86,7 @@ const userResolver = resolve<User, MyContext>({

return user.age >= drinkingAge
},
fullName: async (value, user, context) => {
fullName: (value, user, context) => {
return `${user.firstName} ${user.lastName}`
}
})
Expand All @@ -110,7 +111,7 @@ const userResolver = resolve<User, MyContext>({

return user.age >= drinkingAge
}),
fullName: virtual(async (user, context) => {
fullName: virtual((user, context) => {
return `${user.firstName} ${user.lastName}`
})
})
Expand All @@ -126,7 +127,7 @@ Virtual resolvers should always be used when combined with a [database adapter](

A resolver takes the following options as the second parameter:

- `converter` (optional): A `async (data, context) => {}` function that can return a completely new representation of the data. A `converter` runs before `properties` resolvers.
- `converter` (optional): A `(data, context) => {}` or `async (data, context) => {}` function that can return a completely new representation of the data. A `converter` runs before `properties` resolvers.

```ts
const userResolver = resolve<User, MyContext>(
Expand Down Expand Up @@ -185,22 +186,16 @@ type MessageData = Static<typeof messageDataSchema>

// Resolver that automatically set `userId` and `createdAt`
const messageDataResolver = resolve<Message, HookContext>({
userId: async (value, message, context) => {
// Associate the currently authenticated user
return context.params?.user.id
},
createdAt: async () => {
// Return the current date
return Date.now()
}
// Associate the currently authenticated user
userId: (value, message, context) => context.params?.user.id,
// Return the current date
createdAt: () => Date.now()
})

// Resolver that automatically sets `updatedAt`
const messagePatchResolver = resolve<Message, HookContext>({
updatedAt: async () => {
// Return the current date
return Date.now()
}
// Return the current date
updatedAt: () => Date.now()
})

app.service('users').hooks({
Expand Down Expand Up @@ -267,7 +262,8 @@ type Message = Static<typeof messageSchema>
export const messageResolver = resolve<Message, HookContext>({
user: virtual(async (message, context) => {
// Populate the user associated via `userId`
return context.app.service('users').get(message.userId)
const user = await context.app.service('users').get(message.userId)
return user
})
})

Expand Down Expand Up @@ -301,7 +297,7 @@ type User = Static<typeof userSchema>

export const userExternalResolver = resolve<User, HookContext>({
// Always hide the password for external responses
password: async () => undefined
password: () => undefined
})

// Dispatch should be resolved on every method
Expand All @@ -324,11 +320,11 @@ In order to get the safe data from resolved associations **all services** involv

Query resolvers use the `hooks.resolveQuery(...resolvers)` hook to modify `params.query`. This is often used to set default values or limit the query so a user can only request data they are allowed to see. It is possible to pass multiple resolvers which will run in the order they are passed, using the previous data. `schemaHooks.resolveQuery` can be used as an `around` or `before` hook.

In this example for a `User` schema we are first checking if a user is available in our request. In the case a user is available we are returning the user's ID. Otherwise we return whatever value was provided for `id`.
In this example for a `User` schema we are first checking if a user is available in our request. In the case a user is available we are returning the user's ID. Otherwise we return whatever value was provided for `id`.

`context.params.user` would only be set if the request contains a user. This is usually the case when an external request is made. In the case of an internal request we may not have a specific user we are dealing with, and we will just return `value`.

If we were to receive an internal request, such as `app.service('users').get(123)`, `context.params.user` would be `undefined` and we would just return the `value` which is `123`.
If we were to receive an internal request, such as `app.service('users').get(123)`, `context.params.user` would be `undefined` and we would just return the `value` which is `123`.

```ts
import { hooks as schemaHooks, resolve } from '@feathersjs/schema'
Expand All @@ -353,7 +349,7 @@ export type UserQuery = Static<typeof userQuerySchema>

export const userQueryResolver = resolve<UserQuery, HookContext>({
// If there is an authenticated user, they can only see their own data
id: async (value, query, context) => {
id: (value, query, context) => {
if (context.params.user) {
return context.params.user.id
}
Expand All @@ -372,7 +368,7 @@ app.service('users').hooks({

For a more complicated example. We will make a separate `queryResolver`, called `companyFilterQueryResolver`, that will act as a ownership filter. We will have a `Company` service that is owned by a `User`. We will assume our app has two registered users and two companies. Each user owning one company. For simplicity, `User1` owns `Company1`, and `User2` owns `Company2`

We want to make sure only the user that owns the company can make any requests related to it. Our schema contains a `ownerUser` field, this is the owner of the company. When a request is made to the company schema, we are effectivly filtering our search for companies to be only those whose `ownerUser` matches the requesting user's id.
We want to make sure only the user that owns the company can make any requests related to it. Our schema contains a `ownerUser` field, this is the owner of the company. When a request is made to the company schema, we are effectivly filtering our search for companies to be only those whose `ownerUser` matches the requesting user's id.

So if a `GET /company` request is made by `User1`, our resolver will convert our query to `GET /company?name=Company1&ownerUser={User1.id}`. The result will only return an array of 1 company to `User1`

Expand Down Expand Up @@ -404,12 +400,11 @@ export const companyQueryValidator = getValidator(companyQuerySchema, queryValid
export const companyQueryResolver = resolve<CompanyQuery, HookContext>({})

export const companyFilterQueryResolver = resolve<Company, HookContext>({
ownerUser: async (value, obj, context) => {
ownerUser: (value, obj, context) => {
if (context.params.user) {
return context.params.user.id
}
return value
}
})
```

8 changes: 5 additions & 3 deletions packages/schema/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { BadRequest } from '@feathersjs/errors'
import { Schema } from './schema'

type PromiseOrLiteral<V> = Promise<V> | V

export type PropertyResolver<T, V, C> = ((
value: V | undefined,
obj: T,
context: C,
status: ResolverStatus<T, C>
) => Promise<V | undefined>) & { [IS_VIRTUAL]?: boolean }
) => PromiseOrLiteral<V | undefined>) & { [IS_VIRTUAL]?: boolean }

export type VirtualResolver<T, V, C> = (
obj: T,
context: C,
status: ResolverStatus<T, C>
) => Promise<V | undefined>
) => PromiseOrLiteral<V | undefined>

export const IS_VIRTUAL = Symbol.for('@feathersjs/schema/virtual')

Expand Down Expand Up @@ -40,7 +42,7 @@ export type ResolverConverter<T, C> = (
obj: any,
context: C,
status: ResolverStatus<T, C>
) => Promise<T | undefined>
) => PromiseOrLiteral<T | undefined>

export interface ResolverOptions<T, C> {
schema?: Schema<T>
Expand Down
Loading