feathersjs / feathers

The API and real-time application framework
https://feathersjs.com
MIT License
15.08k stars 752 forks source link

Different Responses When Calling Services with Resolvers Internally vs. Externally #3259

Closed DanazSdiq closed 1 year ago

DanazSdiq commented 1 year ago

Steps to reproduce

I have encountered a weird behavior in trying to write some tests for some of the services that I have. These services use the new Feathers V5 resolvers to join on a few DB tables.

// teachers-with-students.ts

import { hooks as schemaHooks, resolve, virtual } from "@feathersjs/schema";
import { disallow } from "feathers-hooks-common";

const studentsResolver = resolve({
  students: virtual(async (teacher, context) => {
    const studentsIds = (
      await context.app.service("teachers-students").find({
        query: {
          teacher_id: teacher.id,
          $select: ["student_id"],
        },
      })
    ).map((a) => a.student_id);
    const query = { ...context.params.query, id: { $in: studentsIds } };

    return context.app.service("students").find({
      query,
      paginate: false,
    });
  }),
});

export const TeachersWithStudents = async (app) => {
  const path = "teachers-with-students";

  app.use(path, {
    find: (params) => {
      const { students, ...teachersQuery } = params.query || {};
      params.query = students;
      return app.service("teachers").find({
        query: { ...teachersQuery },
        paginate: false,
      });
    },
  });

  app.service(path).hooks({
    around: {
      find: [schemaHooks.resolveExternal(studentsResolver)],
    },
    before: {
      find: [],
      get: [],
      create: [disallow()],
      update: [disallow()],
      patch: [disallow()],
      remove: [disallow()],
    },
  });
};

I have another service that builds on teachers-with-students service, it looks like this:

// schools-with-teachers-with-students.ts

import {resolve, hooks as schemaHooks, virtual} from '@feathersjs/schema';
import {disallow} from 'feathers-hooks-common';

const schoolTeachersResolver = resolve({
  teachers: virtual(async (school, context) => {
    const rows = await context.app
      .service('schools-teachers')
      .find({query: {school_id: school.id, $select: ['teacher_id']}});
    const teacherIds = rows.map((e) => e.teacher_id);

    return context.app.service('teachers-with-students').find({
      query: {id: {$in: teacherIds}},
      paginate: false
    });
  })
});

export const SchoolsWithTeachersWithStudents = (app) => {
  const path = 'schools-with-teachers-with-students';

  app.use(path, {
    find: (params) =>
      app.service('schools').find({query: {...params.query}, paginate: false})
  });

  app.service(path).hooks({
    around: {
      find: [schemaHooks.resolveExternal(schoolTeachersResolver)]
    },
    before: {
      find: [],
      get: [disallow('external')],
      create: [disallow('external')],
      update: [disallow('external')],
      patch: [disallow('external')],
      remove: [disallow('external')]
    }
  });
};

This works as expected when it comes to calling these services within other services or calling it through Postman like:

app.service('schools-with-teachers-with-students')
    .find({query: {
        school_name: 'test-school'
    }});

Expected behavior

The response body for my service should look like this and (does look like this when called through Postman, within other services, or when using a client library like fetch to return results):

[
    id: 1,
    school_name: 'test-school',
    teachers: [
        {
            id: 1,
            name: 'test-teacher',
            students: [
                {
                    id: 1,
                    name: 'test-student'
                }
            ]
        }
    ]
]

Actual behavior

The response body within a test looks like this, teachers array is completely omitted:

[
    id: 1,
    school_name: 'test-school',
]

Module versions (especially the part that's not working):

{
     "dependencies" : {
             "@feathers-plus/batch-loader": "^0.3.6",
             "@feathersjs/authentication": "^5.0.8",
            "@feathersjs/authentication-local": "^5.0.8",
            "@feathersjs/authentication-oauth": "^5.0.8",
            "@feathersjs/configuration": "^5.0.8",
            "@feathersjs/errors": "^5.0.8",
            "@feathersjs/express": "^5.0.8",
            "@feathersjs/feathers": "^5.0.8",
            "@feathersjs/schema": "^5.0.8",
            "@feathersjs/typebox": "^5.0.8"
     }
}

NodeJS version: 18

Operating System: MacOS

DanazSdiq commented 1 year ago

I want to make a correction here. The issue has nothing to do with calling services within a .test.ts files. The issue:

  1. The response from schools-with-teachers-with-students works as expected when calling it from Postman. That means the response looks like this:
      [
         id: 1,
         school_name: 'test-school',
         teachers: [
            {
                  id: 1,
                  name: 'test-teacher',
                  students: [
                     {
                        id: 1,
                        name: 'test-student'
                     }
                  ]
            }
         ]
      ]
  2. The response from schools-with-teachers-with-students does not include teachers and students at all when called within other internal services. The response looks something like this:

      [
         id: 1,
         school_name: 'test-school',
      ]

Is this some hidden behavior of resolveExternal()? Why is there a difference between the responses?

daffl commented 1 year ago

That is what external resolvers are intended for. To return a different response to an external client vs an internal call. If you want it for both, you'd use schemaHooks.resolveResult:

  app.service(path).hooks({
    around: {
      find: [schemaHooks.resolveResult(schoolTeachersResolver)]
    },
    before: {
      find: [],
      get: [disallow('external')],
      create: [disallow('external')],
      update: [disallow('external')],
      patch: [disallow('external')],
      remove: [disallow('external')]
    }
  });
};
DanazSdiq commented 1 year ago

Okay this is very useful, thank you very much. Is there a way @daffl that I can bypass the $select restriction using resolveResult()? Currently, when I query the service without passing $select, I get the following error:

{
    "name": "BadRequest",
    "message": "Invalid query parameter $select",
    "code": 400,
    "className": "bad-request",
    "data": {
        "school_name": "test-school"
    },
    "errors": {}
}

I have to manually add every single column that I want to be included in the response. I am just wondering if I can bypass this?

daffl commented 1 year ago

That shouldn't happen. What does the query and your setup look like?