Skip to content

Commit

Permalink
Add queryInfo and hooks to Query decorator (#1418)
Browse files Browse the repository at this point in the history
* Add queryInfo and hooks to Query decorator

* Add rush change
  • Loading branch information
gonzalojaubert authored Jun 22, 2023
1 parent 2fa6f1c commit e2335b1
Show file tree
Hide file tree
Showing 15 changed files with 175 additions and 45 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@boostercloud/framework-core",
"comment": "Add queryInfo and hooks to query decorator",
"type": "minor"
}
],
"packageName": "@boostercloud/framework-core"
}
2 changes: 1 addition & 1 deletion packages/cli/src/commands/new/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function generateImports(info: QueryInfo): Array<ImportDeclaration> {
const queryFieldTypes = info.fields.map((f) => f.type)
const queryUsesUUID = queryFieldTypes.some((type) => type == 'UUID')

const componentsFromBoosterTypes = ['Register']
const componentsFromBoosterTypes = ['QueryInfo']
if (queryUsesUUID) {
componentsFromBoosterTypes.push('UUID')
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/templates/query.stub
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class {{{ name }}} {
{{/fields}}
) {}

public static async handle(query: {{{ name }}}): Promise<string> {
public static async handle(query: {{{ name }}}, queryInfo?: QueryInfo): Promise<string> {
/* YOUR CODE HERE */
}
}
3 changes: 2 additions & 1 deletion packages/framework-core/src/booster-command-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NotFoundError,
CommandHandlerGlobalError,
TraceActionTypes,
CommandInput,
} from '@boostercloud/framework-types'
import { RegisterHandler } from './booster-register-handler'
import { createInstance, getLogger } from '@boostercloud/framework-common-helpers'
Expand Down Expand Up @@ -50,7 +51,7 @@ export class BoosterCommandDispatcher {
migratedCommandEnvelope.context
)
try {
const commandInput = await applyBeforeFunctions(
const commandInput: CommandInput = await applyBeforeFunctions(
migratedCommandEnvelope.value,
commandMetadata.before,
migratedCommandEnvelope.currentUser
Expand Down
19 changes: 17 additions & 2 deletions packages/framework-core/src/booster-query-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {
NotFoundError,
QueryEnvelope,
QueryHandlerGlobalError,
QueryInfo,
QueryInput,
} from '@boostercloud/framework-types'
import { createInstance, getLogger } from '@boostercloud/framework-common-helpers'
import { BoosterGlobalErrorDispatcher } from './booster-global-error-dispatcher'
import { GraphQLResolverContext } from './services/graphql/common'
import { applyBeforeFunctions } from './services/filter-helpers'

export class BoosterQueryDispatcher {
private readonly globalErrorDispatcher: BoosterGlobalErrorDispatcher
Expand Down Expand Up @@ -35,9 +38,21 @@ export class BoosterQueryDispatcher {

let result: unknown
try {
const queryInstance = createInstance(queryClass, queryEnvelope.value)
const queryInfo: QueryInfo = {
requestID: queryEnvelope.requestID,
responseHeaders: context.responseHeaders,
currentUser: queryEnvelope.currentUser,
context: queryEnvelope.context,
}
const queryInput: QueryInput = await applyBeforeFunctions(
queryEnvelope.value,
queryMetadata.before,
queryEnvelope.currentUser
)
const queryInstance = createInstance(queryClass, queryInput)

logger.debug('Calling "handle" method on query: ', queryClass)
result = await queryClass.handle(queryInstance)
result = await queryClass.handle(queryInstance, queryInfo)
} catch (err) {
const e = err as Error
const error = await this.globalErrorDispatcher.dispatch(new QueryHandlerGlobalError(queryEnvelope, e))
Expand Down
15 changes: 12 additions & 3 deletions packages/framework-core/src/decorators/query.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
/* eslint-disable @typescript-eslint/ban-types */
import { Booster } from '../booster'
import { QueryAuthorizer, QueryInterface, QueryRoleAccess } from '@boostercloud/framework-types'
import {
CommandFilterHooks,
QueryAuthorizer,
QueryInterface,
QueryMetadata,
QueryRoleAccess,
} from '@boostercloud/framework-types'
import { getClassMetadata } from './metadata'
import { BoosterAuthorizer } from '../booster-authorizer'

export function Query(attributes: QueryRoleAccess): <TCommand>(queryClass: QueryInterface<TCommand>) => void {
export function Query(
attributes: QueryRoleAccess & CommandFilterHooks
): <TCommand>(queryClass: QueryInterface<TCommand>) => void {
return (queryClass) => {
Booster.configureCurrentEnv((config): void => {
if (config.queryHandlers[queryClass.name]) {
Expand All @@ -18,7 +26,8 @@ export function Query(attributes: QueryRoleAccess): <TCommand>(queryClass: Query
authorizer: BoosterAuthorizer.build(attributes) as QueryAuthorizer,
properties: metadata.fields,
methods: metadata.methods,
}
before: attributes.before ?? [],
} as QueryMetadata
})
}
}
8 changes: 5 additions & 3 deletions packages/framework-core/src/services/filter-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
UserEnvelope,
ReadModelRequestEnvelope,
ReadModelInterface,
QueryInput,
QueryBeforeFunction,
} from '@boostercloud/framework-types'

export const applyReadModelRequestBeforeFunctions = async (
Expand All @@ -20,10 +22,10 @@ export const applyReadModelRequestBeforeFunctions = async (
}

export const applyBeforeFunctions = async (
commandInput: CommandInput,
beforeHooks: Array<CommandBeforeFunction>,
commandInput: CommandInput | QueryInput,
beforeHooks: Array<CommandBeforeFunction | QueryBeforeFunction>,
currentUser?: UserEnvelope
): Promise<CommandInput> => {
): Promise<CommandInput | QueryInput> => {
let result = commandInput
for (const beforeHook of beforeHooks) {
result = await beforeHook(result, currentUser)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect } from '../../helper/expect'
import { applicationUnderTest } from './setup'
import { waitForIt } from '../../helper/sleep'
import { UUID } from '@boostercloud/framework-types'
import { beforeHookQueryID, beforeHookQueryMultiply } from '../../../src/constants'

describe('Queries end-to-end tests', () => {
context('with public queries', () => {
Expand Down Expand Up @@ -107,6 +108,41 @@ describe('Queries end-to-end tests', () => {
expect(response?.data?.CartsByCountry['spain'].length).to.be.eq(2)
expect(response?.data?.CartsByCountry['india'].length).to.be.eq(1)
})

it('before hook multiply the value by beforeHookQueryMultiply', async () => {
const cartId = beforeHookQueryID
const quantity = random.number({ min: 1 })
await client.mutate({
variables: {
cartId: cartId,
productId: random.uuid(),
quantity: quantity,
},
mutation: gql`
mutation ChangeCartItem($cartId: ID!, $productId: ID!, $quantity: Float!) {
ChangeCartItem(input: { cartId: $cartId, productId: $productId, quantity: $quantity })
}
`,
})

const response = await waitForIt(
() =>
client.query({
variables: {
cartId: cartId,
},
query: gql`
query CartTotalQuantity($cartId: ID!) {
CartTotalQuantity(input: { cartId: $cartId })
}
`,
}),
(result) => result?.data?.CartTotalQuantity != 0
)

expect(response).not.to.be.null
expect(response?.data?.CartTotalQuantity).to.be.eq(beforeHookQueryMultiply * quantity)
})
})

context('when the query requires a specific role', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/framework-integration-tests/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Both used in Cart entity and read-model-integration.ts
export const beforeHookProductId = 'before-hook-product-id'
export const beforeHookQueryID = 'before-hook-query-id'
export const beforeHookQueryMultiply = 10
export const throwExceptionId = 'throw-exception-id'
export const beforeHookException = 'Before hook throwing exception'
export const beforeHookMutationID = 'mutation-but-with-input-changes'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { Booster, Query } from '@boostercloud/framework-core'
import { UUID } from '@boostercloud/framework-types'
import { Booster, NonExposed, Query } from '@boostercloud/framework-core'
import { QueryInfo, QueryInput, UserEnvelope, UUID } from '@boostercloud/framework-types'
import { Cart } from '../entities/cart'
import { queryHandlerErrorCartId, queryHandlerErrorCartMessage } from '../constants'
import {
beforeHookQueryID,
beforeHookQueryMultiply,
queryHandlerErrorCartId,
queryHandlerErrorCartMessage,
} from '../constants'

@Query({
authorize: 'all',
before: [CartTotalQuantity.beforeFn],
})
export class CartTotalQuantity {
public constructor(readonly cartId: UUID) {}
public constructor(readonly cartId: UUID, @NonExposed readonly multiply: number) {}

public static async handle(query: CartTotalQuantity): Promise<number> {
public static async beforeFn(input: QueryInput, currentUser?: UserEnvelope): Promise<QueryInput> {
if (input.cartId === beforeHookQueryID) {
input.multiply = beforeHookQueryMultiply
return input
}
input.multiply = 1
return input
}

public static async handle(query: CartTotalQuantity, queryInfo: QueryInfo): Promise<number> {
if (query.cartId === queryHandlerErrorCartId) {
throw new Error(queryHandlerErrorCartMessage)
}
Expand All @@ -20,7 +35,7 @@ export class CartTotalQuantity {
return cart?.cartItems
.map((cartItem) => cartItem.quantity)
.reduce((accumulator, value) => {
return accumulator + value
return accumulator + value * query.multiply
}, 0)
}
}
8 changes: 7 additions & 1 deletion packages/framework-types/src/concepts/filter-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReadModelInterface } from '.'
import { QueryInput, ReadModelInterface } from '.'
import { UserEnvelope, ReadModelRequestEnvelope } from '../envelope'
import { CommandInput } from './command'

Expand All @@ -16,3 +16,9 @@ export type ReadModelBeforeFunction<TReadModel extends ReadModelInterface = Read
readModelRequestEnvelope: ReadModelRequestEnvelope<TReadModel>,
currentUser?: UserEnvelope
) => Promise<ReadModelRequestEnvelope<TReadModel>>

export interface QueryFilterHooks {
readonly before?: Array<QueryBeforeFunction>
}

export type QueryBeforeFunction = (input: QueryInput, currentUser?: UserEnvelope) => Promise<QueryInput>
15 changes: 13 additions & 2 deletions packages/framework-types/src/concepts/query.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { Class } from '../typelevel'
import { PropertyMetadata } from '@boostercloud/metadata-booster'
import { QueryAuthorizer } from './.'
import { QueryAuthorizer, QueryFilterHooks, UUID } from './.'
import { ContextEnvelope, UserEnvelope } from '../envelope'

export type QueryInput = Record<string, any>

export interface QueryInterface<TQuery = unknown, THandleResult = unknown> extends Class<TQuery> {
handle(query: TQuery): Promise<THandleResult>
handle(query: TQuery, queryInfo?: QueryInfo): Promise<THandleResult>
}

export interface QueryMetadata<TCommand = unknown> {
readonly class: QueryInterface<TCommand>
readonly properties: Array<PropertyMetadata>
readonly methods: Array<PropertyMetadata>
readonly authorizer: QueryAuthorizer
readonly before: NonNullable<QueryFilterHooks['before']>
}

export interface QueryInfo {
requestID: UUID
responseHeaders: Record<string, string>
currentUser?: UserEnvelope
context?: ContextEnvelope
}
54 changes: 29 additions & 25 deletions website/docs/03_architecture/08_queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,32 @@ ReadModels offer read operations over reduced events. On the other hand, Queries
Queries are classes decorated with the `@Query` decorator that have a `handle` method.

```typescript
import { Booster, Query } from '@boostercloud/framework-core'
import { CartReadModel } from '../read-models/cart-read-model'
import { Booster, NonExposed, Query } from '@boostercloud/framework-core'
import { QueryInfo, QueryInput, UserEnvelope, UUID } from '@boostercloud/framework-types'
import { Cart } from '../entities/cart'
import {
beforeHookQueryID,
beforeHookQueryMultiply,
queryHandlerErrorCartId,
queryHandlerErrorCartMessage,
} from '../constants'

@Query({
authorize: 'all',
})
export class CartsByCountry {
public constructor() {}

public static async handle(query: CartsByCountry): Promise<Record<string, Array<CartReadModel>>> {
const carts: Array<CartReadModel> = (await Booster.readModel(CartReadModel)
.filter({
shippingAddress: {
country: {
isDefined: true,
},
},
})
.paginatedVersion(false)
.search()) as Array<CartReadModel>

return carts.reduce((group: Record<string, Array<CartReadModel>>, cartReadModel: CartReadModel) => {
const country = cartReadModel.shippingAddress?.country || ''
group[country] = group[country] ?? []
group[country].push(cartReadModel)
return group
}, {})
export class CartTotalQuantity {
public constructor(readonly cartId: UUID, @NonExposed readonly multiply: number) {}

public static async handle(query: CartTotalQuantity, queryInfo: QueryInfo): Promise<number> {
const cart = await Booster.entity(Cart, query.cartId)
if (!cart || !cart.cartItems || cart.cartItems.length === 0) {
return 0
}
return cart?.cartItems
.map((cartItem) => cartItem.quantity)
.reduce((accumulator, value) => {
return accumulator + value
}, 0)
}
}
```
Expand Down Expand Up @@ -71,6 +70,11 @@ Queries classes can also be created by hand and there are no restrictions. The s

Each query class must have a method called `handle`. This function is the command handler, and it will be called by the framework every time one instance of this query is submitted. Inside the handler you can run validations, return errors and query entities to make decisions.

Handler function receive a QueryInfo object to let users interact with the execution context. It can be used for a variety of purposes, including:

* Access the current signed in user, their roles and other claims included in their JWT token
* Access the request context or alter the HTTP response headers

### Validating data

Booster uses the typed nature of GraphQL to ensure that types are correct before reaching the handler, so you don't have to validate types.
Expand Down Expand Up @@ -98,7 +102,7 @@ For every query, Booster automatically creates the corresponding GraphQL query.
export class CartTotalQuantityQuery {
public constructor(readonly cartId: UUID) {}

public static async handle(query: CartTotalQuantity): Promise<number> {
public static async handle(query: CartTotalQuantity, queryInfo: QueryInfo): Promise<number> {
const cart = await Booster.entity(Cart, query.cartId)
if (!cart || !cart.cartItems || cart.cartItems.length === 0) {
return 0
Expand All @@ -118,4 +122,4 @@ You will get the following GraphQL query and subscriptions:
query CartTotalQuantityQuery($cartId: ID!): Float!
```

> [!NOTE] Query subscriptions are not supported yet
> [!NOTE] Query subscriptions are not supported yet
19 changes: 19 additions & 0 deletions website/docs/06_graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,25 @@ function beforeFn(input: CommandInput, currentUser?: UserEnvelope): CommandInput
As you can see, we just check if the `cartUserId` is equal to the `currentUser.id`, which is the user id extracted from the auth token. This way, we can throw an exception and avoid this user to call this command.
## Adding before hooks to your queries
You can use `before` hooks also in your queries, and [they work as the Read Models ones](#Adding-before-hooks-to-your-read-models), with a slight difference: **we don't modify `filters` but `inputs` (the parameters sent with a query)**. Apart from that, it's pretty much the same, here's an example:
```typescript
@Query({
authorize: 'all',
before: [CartTotalQuantity.beforeFn],
})
export class CartTotalQuantity {
public constructor(readonly cartId: UUID, @NonExposed readonly multiply: number) {}

public static async beforeFn(input: QueryInput, currentUser?: UserEnvelope): Promise<QueryInput> {
input.multiply = 100
return input
}
}
```
## Reading events
You can also fetch events directly if you need. To do so, there are two kind of queries that have the following structure:
Expand Down

0 comments on commit e2335b1

Please sign in to comment.