How to build nested responses in an Apigility driv

2020-04-21 04:36发布

问题:

I'm developing a RESTful web application -- Apigility driven and based on the Zend Framework 2. For the model layer I'm using the ZfcBase DbMapper. The model essentially consists of two entities: Project and Image (1:n) and is currently implemented like this:

ProjectCollection extends Paginator
ProjectEntity
ProjectMapper extends AbstractDbMapper
ProjectService implements ServiceManagerAwareInterface
ProjectServiceFactory implements FactoryInterface

The same structure for Image.

When the resource (/projects[/:id]) is requested, the responsed project entity/entities should contain a list of its/their Image entities.

So, how can/should this 1:n structure be implemented?

Subquestions:

  1. Does [DbMapper] provide some "magic" for retrieving such tree structures "automatically" without to write JOINs (or use an ORM)?

  2. Does [Apigility] provide some "magic" for building nested responses?


{
    "_links": {
        "self": {
            "href": "http://myproject-api.misc.loc/projects?page=1"
        },
        "first": {
            "href": "http://myproject-api.misc.loc/projects"
        },
        "last": {
            "href": "http://myproject-api.misc.loc/projects?page=1"
        }
    },
    "_embedded": {
        "projects": [
            {
                "id": "1",
                "title": "project_1",
                "images": [
                    {
                        "id": "1",
                        "title": "image_1"
                    },
                    {
                        "id": "2",
                        "title": "image_2"
                    }
                ],
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/projects/1"
                    }
                }
            },
            {
                "id": "2",
                "title": "project_2",
                "images": [
                    {
                        "id": "3",
                        "title": "image_3"
                    },
                    {
                        "id": "4",
                        "title": "image_4"
                    }
                ],
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/projects/1"
                    }
                }
            }
        ]
    },
    "page_count": 1,
    "page_size": 25,
    "total_items": 1
}

EDIT

The output I'm currentliy getting is:

/projects/:id

{
    "id": "1",
    "title": "...",
    ...
    "_embedded": {
        "images": [
            {
                "id": "1",
                "project_id": "1",
                "title": "...",
                ...
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/images/1"
                    }
                }
            },
            {
                "id": "2",
                "project_id": "1",
                "title": "...",
                ...
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/images/2"
                    }
                }
            },
            {
                "id": "3",
                "project_id": "1",
                "title": "...",
                ...
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/images/3"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://myproject-api.misc.loc/projects/1"
        }
    }
}

So it works for one single object. But not for collections, where single items include futher collections:

/projects

{
    "_links": {
        "self": {
            "href": "http://myproject-api.misc.loc/projects?page=1"
        },
        "first": {
            "href": "http://myproject-api.misc.loc/projects"
        },
        "last": {
            "href": "http://myproject-api.misc.loc/projects?page=24"
        },
        "next": {
            "href": "http://myproject-api.misc.loc/projects?page=2"
        }
    },
    "_embedded": {
        "projects": [
            {
                "id": "1",
                "title": "...",
                ... <-- HERE I WANT TO GET ["images": {...}, {...}, {...}]
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/projects/1"
                    }
                }
            },
            {
                "id": "2",
                "title": "...",
                ... <-- HERE I WANT TO GET ["images": {...}, {...}, {...}]
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/projects/2"
                    }
                }
            },
            {
                "id": "3",
                "title": "...",
                ... <-- HERE I WANT TO GET ["images": {...}, {...}, {...}]
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/projects/3"
                    }
                }
            }
        ]
    },
    "page_count": 24,
    "page_size": 3,
    "total_items": 72
}

EDIT

I edited my code and made a step to the goal.

It could not work, since my ProjectService#getProjects() was just returning the projects' data from the database, not enriched with the images:

public function getProjects() {
    return $this->getMapper()->findAll();
}

edited to:

public function getProjects() {
    $projects = $this->getMapper()->findAll();
    foreach ($projects as $key => $project) {
        $images = $this->getImageService()->getImagesForProject($project['id']);
        $projects[$key]['images'] = $images;
    }
    return $projects;
}

and the ProjectMapper#findAll()

public function findAll() {
    $select = $this->getSelect();
    $adapter = $this->getDbAdapter();
    $paginatorAdapter = new DbSelect($select, $adapter);
    $collection = new ProjectCollection($paginatorAdapter);
    return $collection;
}

edited to:

public function findAll() {
    $select = $this->getSelect();
    $adapter = $this->getDbAdapter();
    $paginatorAdapter = new DbSelect($select, $adapter);
    // @todo Replace the constants with data from the config and request.
    $projects = $paginatorAdapter->getItems(0, 2);
    $projects = $projects->toArray();
    return $projects;
}

Now I get the wished output:

{
    "_links": {
        "self": {
            "href": "http://myproject-api.misc.loc/projects"
        }
    },
    "_embedded": {
        "projects": [
            {
                "id": "1",
                "title": "...",
                ...
                "_embedded": {
                    "images": [
                        {
                            "id": "1",
                            "project_id": "1",
                            "title": "...",
                            ...
                            "_links": {
                                "self": {
                                    "href": "http://myproject-api.misc.loc/images/1"
                                }
                            }
                        },
                        {
                            ...
                        },
                        {
                            ...
                        }
                    ]
                },
                "_links": {
                    "self": {
                        "href": "http://myproject-api.misc.loc/projects/1"
                    }
                }
            },
            {
                "id": "2",
                "title": "...",
                ...
                "_embedded": {
                    "images": [
                        ...
                    ]
                },
                ...
            }
        ]
    },
    "total_items": 2
}

But it's a little bit crappy solution, isn't it? What I'm actually doing, is: I'm just replacing a part of the Apigility data retrieving functionality... Anyway, I don't like this solution and want to find a better one (an "Apigility conform solution").

回答1:

I have finally found a solution. (Thanks once again @ poisa for his solution suggestion on GitHub.) In short, the idea is to enrich the (projects) list items with nested (image) items lists on the hydration step. I actually don't really like this way, since it's too much model logic on the hydration level for me. But it works. Here we go:

/module/Portfolio/config/module.config.php

return array(
    ...
    'zf-hal' => array(
        'metadata_map' => array(
            ...
            'Portfolio\\V2\\Rest\\Project\\ProjectEntity' => array(
                'entity_identifier_name' => 'id',
                'route_name' => 'portfolio.rest.project',
                'route_identifier_name' => 'id',
                'hydrator' => 'Portfolio\\V2\\Rest\\Project\\ProjectHydrator',
            ),
            'Portfolio\\V2\\Rest\\Project\\ProjectCollection' => array(
                'entity_identifier_name' => 'id',
                'route_name' => 'portfolio.rest.project',
                'route_identifier_name' => 'id',
                'is_collection' => true,
            ),
            ...
        ),
    ),
);

Portfolio\Module

class Module implements ApigilityProviderInterface {

    ...

    public function getHydratorConfig() {
        return array(
            'factories' => array(
                // V2
                'Portfolio\\V2\\Rest\\Project\\ProjectHydrator' => function(ServiceManager $serviceManager) {
                    $projectHydrator = new ProjectHydrator();
                    $projectHydrator->setImageService($serviceManager->getServiceLocator()->get('Portfolio\V2\Rest\ImageService'));
                    return $projectHydrator;
                }
            ),
        );
    }

    ...

}

Portfolio\V2\Rest\Project\ProjectHydrator

namespace Portfolio\V2\Rest\Project;

use Zend\Stdlib\Hydrator\ClassMethods;
use Portfolio\V2\Rest\Image\ImageService;

class ProjectHydrator extends ClassMethods {

    /**
     * @var ImageService
     */
    protected $imageService;

    /**
     * @return ImageService the $imageService
     */
    public function getImageService() {
        return $this->imageService;
    }

    /**
     * @param ImageService $imageService
     */
    public function setImageService(ImageService $imageService) {
        $this->imageService = $imageService;
        return $this;
    }

    /*
     * Doesn't need to be implemented:
     * the ClassMethods#hydrate(...) handle the $data already as wished.
     */
    /*
    public function hydrate(array $data, $object) {
        $object = parent::hydrate($data, $object);
        if ($object->getId() !== null) {
            $images = $this->imageService->getImagesForProject($object->getId());
            $object->setImages($images);
        }
        return $object;
    }
    */

    /**
     * @see \Zend\Stdlib\Hydrator\ClassMethods::extract()
     */
    public function extract($object) {
        $array = parent::extract($object);
        if ($array['id'] !== null) {
            $images = $this->imageService->getImagesForProject($array['id']);
            $array['images'] = $images;
        }
        return $array;
    }

}

Portfolio\V2\Rest\Project\ProjectMapperFactory

namespace Portfolio\V2\Rest\Project;

use Zend\ServiceManager\ServiceLocatorInterface;

class ProjectMapperFactory {

    public function __invoke(ServiceLocatorInterface $serviceManager) {
        $mapper = new ProjectMapper();
        $mapper->setDbAdapter($serviceManager->get('PortfolioDbAdapter_V2'));
        $mapper->setEntityPrototype($serviceManager->get('Portfolio\V2\Rest\Project\ProjectEntity'));
        $projectHydrator = $serviceManager->get('HydratorManager')->get('Portfolio\\V2\\Rest\\Project\\ProjectHydrator');
        $mapper->setHydrator($projectHydrator);
        return $mapper;
    }

}

Portfolio\V2\Rest\Project\ProjectMapper

namespace Portfolio\V2\Rest\Project;

use ZfcBase\Mapper\AbstractDbMapper;
use Zend\Paginator\Adapter\DbSelect;
use Zend\Db\ResultSet\HydratingResultSet;

class ProjectMapper extends AbstractDbMapper {

    ...

    /**
     * Provides a collection of all the available projects.
     *
     * @return \Portfolio\V2\Rest\Project\ProjectCollection
     */
    public function findAll() {
        $resultSetPrototype = new HydratingResultSet(
            $this->getHydrator(),
            $this->getEntityPrototype()
        );
        $select = $this->getSelect();
        $adapter = $this->getDbAdapter();
        $paginatorAdapter = new DbSelect($select, $adapter, $resultSetPrototype);
        $collection = new ProjectCollection($paginatorAdapter);
        return $collection;
    }

    /**
     * Provides a project by ID.
     *
     * @param int $id
     * @return \Portfolio\V2\Rest\Project\ProjectEntity
     */
    public function findById($id) {
        $select = $this->getSelect();
        $select->where(array(
            'id' => $id,
        ));
        $entity = $this->select($select)->current();
        return $entity;
    }

    ...

}

As I already said in my post on GitHub, it would be great to get a feedback from someone from the Apigility core team, wheter this solution is "Apigility conform" and, if not, what is a better/"correct" solution.



回答2:

I have no experience with db-mapper, but I think can answer question 2 for you.

If your extracted project resource (an array) has a key images that holds an object of type Hal\Collection it will automatically extract this collection and render it as you show in your Hal example.

This "magic" happens because extractEmbeddedCollection is called in the renderEntity method in Hal.php on line 563.

EDIT

You write that you want:

["images": {...}, {...}, {...}]

But what you should actually aim for is this:

{
    "id": "2",
    "title": "...",
    "_links": {
        "self": {
            "href": "http://myproject-api.misc.loc/projects/2"
        }
    },
    "_embedded": {
        "images": [ 
            {...}, 
            {...}, 
            {...}
        ]
    }
}

How do you extract your objects? Did you register a hydrator in your metadata map?

You should try to return something like this:

use ZF\Hal\Collection

...

$images = new Collection($arrayOfImages);

$project['images'] = $images;

then it should work (I don't know how else to explain it).