支持 ApolloServer 中的 Objection `$formatJson`

Posted

技术标签:

【中文标题】支持 ApolloServer 中的 Objection `$formatJson`【英文标题】:Support for Objection `$formatJson` in ApolloServer 【发布时间】:2020-02-25 05:15:37 【问题描述】:

我是Objection.js 和GraphQL 的忠实拥护者,我正努力让他们一起为我的业务开发一个新的API。不幸的是,我遇到了一些困难。

Objection 的一大优点是model data lifecyle,它允许您在模型类中指定如何在各种格式之间转换其属性。您可以以一种方式将其存储在数据库中,以另一种方式在代码中使用它,并以另一种方式在将其发送给客户端之前对其进行序列化。

例如,$formatJson 可用于在您使用模型时更改作为 JS Date 对象的 Date 属性,但在响应中发送时会转换为 ISO 字符串:

$formatJson(json: Pojo): Pojo 
    json = super.$formatJson(json);
    if (json.lastActive) json.lastActive = json.lastActive.toISOString();
    return json;

此方法在实例的#toJSON 方法中调用,通常在按照here 所述进行字符串化时调用。

ApolloServer(特别是我正在使用的apollo-server-koa)并没有直接对这些模型实例进行字符串化。它似乎(合理地)将属性子集复制到新对象,将数据与其实例方法分开。因此,#$formatJson 将永远不会被调用,并且我的日期作为时间戳返回,因为这就是 JS 日期默认字符串化的方式。

似乎需要以某种方式在解析器函数和从它们的返回值复制属性之间注入一些#toJSON 调用。我查看了formatResponsehere,但看起来它已经从模型类中分离出来后接收数据。

任何熟悉 ApolloServer 的人能指出我正确的方向吗?我需要研究某种插件 API 吗?

我发现objection-graphql 非常酷,但看起来它通过在***解析器中处理整个查询并在解析器返回之前对所有内容递归调用#toJSON 来处理这个问题。默认解析器会从预先加载的结构中提取所有其他内容。超级酷,但它看起来不够灵活,无法满足我的需求。真的,我只是想解决这个特定的问题,而不是让我的整个 api 变魔术。 :\


编辑:

我对此进行了更多研究,现在我想更清楚地说明这个问题,因为我已经更好地掌握了 GraphQL 的实际实现方式。

GraphQL 通过调用解析器并逐个属性地组装响应来工作。首先调用您的***解析器,然后在它们返回后,调用下一层的每个属性的解析器,依此类推,直到只剩下叶子。每个叶子的返回值最终分配到一个对象结构中,该结构被字符串化为 JSON。这意味着这些属性最初来自的对象上的任何实例方法都将在此时完全丢失。因此,如果您希望使用这些实例方法(在本例中为 Objection 的 $formatJSON)对整个结果进行某种“最终”转换,您将会遇到一些困难。

困难

可以使用graphql-middleware 拦截任何解析器的结果并递归地调用您对其内容的转换,使用如下:

import  isArray, isObjectLike, map, mapValues  from 'lodash';
import  resolvers, typeDefs  from './api';
import  ApolloServer  from 'apollo-server-koa';
import Koa from 'koa';
import  Model  from 'objection';
import  applyMiddleware  from 'graphql-middleware';

function toResponse(value: any): any 
    // If this isn't an object or an array, just return it.
    if (!isObjectLike(value)) return value;

    // If this is an instance of Model, convert it using #$toJson
    if (value instanceof Model) return value.$toJson();

    // Create a recursive mapping function.
    const mapFn = (item: any) => toResponse(item);

    /*
     * If this an array, convert each one of its items with the mapping
     * function.
     */
    if (isArray(value)) return map(value, mapFn);

    /*
     * Otherwise, this must be an object. Convert its values with the mapping
     * function.
     */
    return mapValues(value, mapFn);


const schema = applyMiddleware(
    makeExecutableSchema( typeDefs, resolvers ),
    async(
        resolve,
        root,
        args,
        info,
    ) => toResponse(await resolve(root, args, ctx, info)),
);

const app = new Koa();
const server = new ApolloServer( schema );
server.applyMiddleware( app );

app.listen(3000);

这里的问题是,转换将发生在您的较低级别解析器被调用以更深入地遍历图表之前。因此,这些解析器的第一个参数将接收这些转换后的模型实例,这些将不再具有您可能想要的原始模型类的任何有用的实例方法(如$relatedQuery)实现解析器。

例如,我可能有一个像这样的简单架构:

type Query 
    person(id: Int!): Person


type Person 
    id: Int!
    name: String!
    children: [Person!]

并像这样实现我的解析器:


    Query: 
        person: (
            root: undefined,
            args:  id: number ,
        ) => Person.query().findById(args.id),
    ,
    Person: 
        children: (person: Person) => person.$relatedQuery('children'),
    ,

使用上面显示的中间件设置,Person.children 的解析器将不起作用,因为它的 person 参数不会接收到 Model 的实例,因此它不会有 $relatedQuery 实例方法。

原因:

为什么有人想要这个?除了只是想使用 Objection 的最佳功能之一(正如 Herku 正确指出的那样,在这种情况下,使用更特定于 GQL 的东西可能会更好地处理它),还有与其他库集成的可能性,这些库对关于Objection 所做的 API 框架。

为了简单起见,我最初忽略了这一点,但我还有一个(当前是专有的)权限库,它能够过滤查询结果以根据各种因素删除不允许用户查看的属性。我目前计划集成它的方式还涉及需要原始模型实例的模型的“后解析器”转换。我可能会也可能不会走这条路,目前我不想分享太多关于这个库是如何工作的,但希望这能让情况更清楚一些。

我找到了一种似乎可行的变通方法,并且很快就会发布答案,除非其他人想出更好的方法。

【问题讨论】:

【参考方案1】:

所以在 GraphQL 中,对象不会直接转换为 JSON。相反,只有少数地方将值转换为 JSON 值。那是叶子类型的内部。

叶子类型是 GraphQL 中没有子选择的类型。具体来说,它们是 ScalarEnum。让我们看看我们如何在内部使用值但在外部使用不同的值:

对于枚举类型,我们可以在内部和外部定义不同的值。您可以将任何您想要的内容分配给 value 属性:

import  GraphQLEnumType  from 'graphql';

const MyEnum = new GraphQLEnumType(
  name: 'MyEnum',
  values: 
    VALUE_A: 
      value: 'value-a',
    ,
    VALUE_B: 
      value: 'value-b',
    
  
);

如果你使用 Apollo 和 graphql-tools,你必须这样做 slightly differently。现在来看更有趣的 Scalar 案例。在您的示例中,日期可以由日期标量序列化。

import  GraphQLScalarType  from 'graphql';
import  Kind  from 'graphql/language';

const Date = new GraphQLScalarType(
  name: 'Date',
  description: 'Date custom scalar type',
  parseValue(value) 
    return new Date(value); // value from JSON (from variable)
  ,
  serialize(value) 
    return value.toISOString(); // serialization happens here
  ,
  parseLiteral(ast) 
    if (ast.kind === Kind.STRING) 
      return new Date(ast.value); // value inlined in query
    
    return null;
  ,
);

现在我们序列化为 ISO 字符串并在内部使用 javascript 日期!阿波罗又是a bit different。

如果您不想为此编写自己的标量类型,请查看Uri's GraphQL Scalars。或者 GraphQL ISO Date 在我的示例中几乎可以做到这一点。

【讨论】:

显然,自定义标量类型可能有助于将日期添加到模式中,但我的问题是专门关于将 Objection 与 Apollo 集成的问题,而日期格式只是可以使用的转换示例异议的$formatJson 基本上,我只是希望能够将模型实例返回给我的解析器,并让 Apollo 在序列化完整响应数据之前对其调用$toJson 我认为 Apollo/GraphQL 以不同的方式解决了 $formatJson 所做的事情。我在这里概述了它在 GraphQL 中的完成方式。我认为您不需要$formatJson,因为这就是 GraphQL 的全部意义所在。我会对你的提议比阿波罗所做的更好感兴趣。因此,如果您找到解决方案,请分享! :) 它不一定更好,它只是更符合 Objection 的设计方式,它旨在不将您的序列化逻辑与您为实体提供服务的 API 类型耦合。例如,如果我想通过非 GQL 端点为相同的实体提供服务,我必须在我的自定义标量类型(由 GraphQL 使用)和我用来为这些其他执行序列化的任何内容之间复制序列化逻辑API。 很可能我所要求的对于大多数 GQL 实现的工作方式在架构上是不可行的,现在看起来确实如此。不过,为了彻底,我想排除这种可能性。【参考方案2】:

我发现的解决方法包括使用我编辑中详述的相同 graphql-middleware 方法,以及在转换后的对象上存储对原始模型实例的引用。这些引用以Symbol 为关键字,以保证不会与结果中的任何属性名称发生冲突。

以下将用作我所有数据库模型的基类:

import  Model as ObjectionModel, Pojo  from 'objection';

export const entitySymbol = Symbol('Model instance');

export class Model extends ObjectionModel 
    $toJson(): Pojo 
        const result = super.$toJson();
        // Retain a reference to the original entity.
        result[entitySymbol] = this;
        return result;
    

同时,此模块提供了一个函数,可用于包装我的解析器并在必要时提取引用:

import  Model, entitySymbol  from './model';
import  IFieldResolver  from 'apollo-server-koa';
import  GraphQLResolveInfo  from 'graphql';

export interface ResponseEntity<T> 
    [entitySymbol]: T;


export type EntityResolver<
    TEntity extends Model,
    TContext = any,
    TArgs = Record<string, any>,
> = (
    entity: TEntity,
    args: TArgs,
    context: TContext,
    info: GraphQLResolveInfo,
) => any;

export function entityResolver<
    TEntity extends Model,
    TContext = any,
    TArgs = Record<string, any>,
>(
    resolver: EntityResolver<TEntity, TContext, TArgs>,
): IFieldResolver<ResponseEntity<TEntity>, TContext, TArgs> 
    return (
        obj: ResponseEntity<TEntity>,
        args: TArgs,
        context: TContext,
        info: GraphQLResolveInfo,
    ) => resolver(obj[entitySymbol], args, context, info);

现在我可以像这样实现我的解析器:


    Query: 
        person: (
            root: undefined,
            args:  id: number ,
        ) => Person.query().findById(args.id),
    ,
    Person: 
        children: entityResolver(
            (person: Person) => person.$relatedQuery('children'),
        ),
    ,

这个符号的使用感觉有点老套,但它似乎足够健壮,最终我看不出有什么更好的方法来做到这一点。

【讨论】:

【参考方案3】:

Objection 有静态方法fromJson 来创建一个模型实例,您可以像这样使用它:


    Query: 
        ...
    ,
    Person: 
        children: (person: Person) => Person.fromJson(person).$relatedQuery('children'),
    ,

【讨论】:

所以这可以代替符号工作,但它依赖于$formatJson 不会破坏$relatedQuery 工作所需的任何属性的假设。例如,如果使用它(无论出于何种原因)过滤掉与其他 Person 记录的 mother_idfather_id 字段相对应的 Person 的 id 字段,则 $relatedQuery 可能会失败。在这个特定的例子中,这听起来很做作,但在实践中可能会出现。

以上是关于支持 ApolloServer 中的 Objection `$formatJson`的主要内容,如果未能解决你的问题,请参考以下文章

错误:在生产中启动 ApolloServer for Express 时找不到模块/ApolloServer/protobufjs/minimal

在特定路径中初始化 ApolloServer

如何在 ApolloServer 中添加响应头?

ApolloServer 模式选项

如何在 ApolloServer 中获取 req.user

ApolloServer 2.0 上下文和 GraphQL API 的公共/私有部分