├── OutlandishRestBundle.php ├── composer.json ├── DependencyInjection └── OutlandishRestExtension.php ├── Resources └── config │ └── services.yml ├── EventDispatcher └── ExceptionListener.php ├── Serializer ├── DoctrineProxySubscriber.php └── DoctrineProxyHandler.php ├── README.md ├── Routing └── RestLoader.php └── Controller └── RestController.php /OutlandishRestBundle.php: -------------------------------------------------------------------------------- 1 | load('services.yml'); 27 | } 28 | } -------------------------------------------------------------------------------- /Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | outlandish_rest.proxy_handler: 3 | class: Outlandish\RestBundle\Serializer\DoctrineProxyHandler 4 | arguments: [@doctrine.orm.entity_manager] 5 | tags: 6 | - name: jms_serializer.subscribing_handler 7 | outlandish_rest.routing_loader: 8 | class: Outlandish\RestBundle\Routing\RestLoader 9 | arguments: [@doctrine.orm.entity_manager] 10 | tags: 11 | - name: routing.loader 12 | outlandish_rest.exception_listener: 13 | class: Outlandish\RestBundle\EventDispatcher\ExceptionListener 14 | arguments: [@kernel] 15 | tags: 16 | - name: kernel.event_listener 17 | event: kernel.exception 18 | method: onKernelException 19 | 20 | # override JMS Serializer's Doctrine Proxy Subscriber to allow us to disable it 21 | parameters: 22 | jms_serializer.doctrine_proxy_subscriber.class: Outlandish\RestBundle\Serializer\DoctrineProxySubscriber -------------------------------------------------------------------------------- /EventDispatcher/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | kernel = $kernel; 19 | } 20 | 21 | /** 22 | * Return array of JSON encoded exceptions 23 | * 24 | * @param GetResponseForExceptionEvent $event 25 | */ 26 | public function onKernelException(GetResponseForExceptionEvent $event) { 27 | $controller = $event->getRequest()->attributes->get('_controller'); 28 | 29 | //check if the exception was generated by this bundle 30 | if (substr($controller, 0, strrpos($controller, '\\')) == 'Outlandish\\RestBundle\\Controller') { 31 | $data = FlattenException::create($event->getException())->toArray(); 32 | if ($this->kernel->isDebug()) { 33 | foreach ($data as $i => $exception) { 34 | unset($data[$i]['trace']); 35 | unset($data[$i]['class']); 36 | } 37 | } 38 | $event->setResponse(new JsonResponse($data)); 39 | } 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /Serializer/DoctrineProxySubscriber.php: -------------------------------------------------------------------------------- 1 | 'serializer.pre_serialize', 'method' => 'onPreSerialize'), 19 | array('event' => 'serializer.pre_deserialize', 'method' => 'onPreDeserialize'), 20 | ); 21 | } 22 | 23 | public function onPreSerialize(PreSerializeEvent $event) { 24 | if ($this->enabled) { 25 | //by default behave same as parent class 26 | parent::onPreSerialize($event); 27 | } elseif ($event->getObject() instanceof Proxy) { 28 | //if enabled and class is a Doctrine proxy, set virtual type for custom handler 29 | $event->setType('Outlandish/Virtual/Proxy'); 30 | } elseif ($event->getObject() instanceof PersistentCollection) { 31 | //if enabled and class is a Doctrine proxy, set virtual type for custom handler 32 | $event->setType('Outlandish/Virtual/PersistentCollection'); 33 | } 34 | } 35 | 36 | public function onPreDeserialize(PreDeserializeEvent $event) { 37 | $type = $event->getType(); 38 | $data = $event->getData(); 39 | if (substr($type['name'], 0, strrpos($type['name'], '\\')) == 'Outlandish\SiteBundle\Entity' && is_scalar($data)) { 40 | $event->setType('Outlandish/Virtual/Reference', array('originalType' => $type['name'])); 41 | } 42 | } 43 | 44 | /** 45 | * @return boolean 46 | */ 47 | public function getEnabled() { 48 | return $this->enabled; 49 | } 50 | 51 | /** 52 | * @param boolean $enabled 53 | */ 54 | public function setEnabled($enabled) { 55 | $this->enabled = $enabled; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REST API in a Box 2 | 3 | The `OutlandishRestBundle` provides a REST-like API for the Doctrine entities in a Symfony application. It is highly 4 | opinionated and requires no configuration which makes it an excellent choice for rapid prototyping with JavaScript 5 | frameworks such as Angular, Ember and Backbone. 6 | 7 | Features: 8 | 9 | - automatically generates routes 10 | - uses JMSSerializer bundle 11 | - uses Symfony validator component 12 | - serializes errors and exceptions 13 | - supports Doctrine associations 14 | - uses JSON only 15 | - pagination 16 | - simple data queries using FIQL 17 | - authentication can be added using the security component 18 | 19 | For example if you have `Acme\FooBundle\Entity\Bar` then you can do the following: 20 | 21 | GET /api/bar 22 | // returns [{"id":1, ...}, {"id":2, ...}] 23 | 24 | GET /api/bar/1 25 | // returns {"id":1, ...} 26 | 27 | POST /api/bar 28 | {"foo":"baz", ...} 29 | // returns {"id":3, "foo":"baz", ...} 30 | 31 | PUT /api/bar/2 32 | {"foo":"buzz", ...} 33 | // returns {"id":2, "foo":"buzz", ...} 34 | 35 | DELETE /api/bar/3 36 | // returns nothing 37 | 38 | GET /api/bar?id=gt=2&foo=baz 39 | // returns [{"id":3, ...}] 40 | 41 | GET /api/bar?per_page=2&offset=1 42 | // returns [{"id":2, ...}, {"id":3, ...}] 43 | 44 | GET /api/bar/0 45 | // returns [{"message":"Entity not found"}] 46 | 47 | POST and PUT requests expect JSON encoded entity data in the request body. 48 | 49 | On error, a status code of 400, 404 or 500 is returned and the response body is an array of messages. 50 | 51 | 52 | ## Installation 53 | 54 | ### 1. Add to `composer.json` 55 | 56 | "require": { 57 | "outlandish/rest-bundle": "dev-master", 58 | }, 59 | 60 | ### 2. Run `composer update` 61 | 62 | ### 3. Add to `AppKernel.php` 63 | 64 | public function registerBundles() 65 | { 66 | $bundles = array( 67 | //... 68 | new Outlandish\RestBundle\OutlandishRestBundle(), 69 | ); 70 | 71 | return $bundles; 72 | } 73 | 74 | ### 5. Edit `app/config/routing.yml` 75 | 76 | #... 77 | 78 | outlandish_rest: 79 | resource: . 80 | type: outlandish_rest 81 | prefix: /api 82 | -------------------------------------------------------------------------------- /Serializer/DoctrineProxyHandler.php: -------------------------------------------------------------------------------- 1 | em = $em; 25 | } 26 | 27 | public static function getSubscribingMethods() { 28 | return array( 29 | array( 30 | 'direction' => GraphNavigator::DIRECTION_SERIALIZATION, 31 | 'format' => 'json', 32 | 'type' => 'Outlandish/Virtual/Proxy', //not a real class name 33 | 'method' => 'handleProxy', 34 | ), 35 | array( 36 | 'direction' => GraphNavigator::DIRECTION_SERIALIZATION, 37 | 'format' => 'json', 38 | 'type' => 'Outlandish/Virtual/PersistentCollection', //not a real class name 39 | 'method' => 'handleCollection', 40 | ), 41 | array( 42 | 'direction' => GraphNavigator::DIRECTION_DESERIALIZATION, 43 | 'format' => 'json', 44 | 'type' => 'Outlandish/Virtual/Reference', //not a real class name 45 | 'method' => 'handleReference', 46 | ) 47 | ); 48 | } 49 | 50 | /** 51 | * Serialize an entity as a scalar ID string 52 | */ 53 | public function handleProxy(JsonSerializationVisitor $visitor, $entity, array $type, Context $context) { 54 | return $visitor->visitInteger($entity->getId(), $type, $context); 55 | } 56 | 57 | /** 58 | * Serialize a collection of entities to an array of ID strings 59 | */ 60 | public function handleCollection(JsonSerializationVisitor $visitor, PersistentCollection $collection, array $type, Context $context) { 61 | $data = array(); 62 | foreach ($collection as $entity) { 63 | $data[] = $this->handleProxy($visitor, $entity, $type, $context); 64 | } 65 | return $data; 66 | } 67 | 68 | /** 69 | * Deserialize scalar IDs to Doctrine proxy objects 70 | */ 71 | public function handleReference(JsonDeserializationVisitor $visitor, $id, array $type, Context $context) { 72 | return $this->em->getReference($type['params']['originalType'], $id); 73 | } 74 | } -------------------------------------------------------------------------------- /Routing/RestLoader.php: -------------------------------------------------------------------------------- 1 | em = $em; 20 | } 21 | 22 | /** 23 | * Dynamically create routes for entities 24 | * 25 | * @param mixed $resource 26 | * @param string $type 27 | * @throws \RuntimeException 28 | * @return RouteCollection 29 | */ 30 | public function load($resource, $type = null) { 31 | if (true === $this->loaded) { 32 | throw new \RuntimeException('Do not add the "outlandish_rest" loader twice'); 33 | } 34 | 35 | $classNames = array(); 36 | $simpleNames = array(); 37 | $classes = $this->em->getMetadataFactory()->getAllMetadata(); 38 | foreach ($classes as $class) { 39 | $name = $class->getName(); 40 | $classNames[] = $name; 41 | $simpleNames[] = Container::underscore(substr($name, strrpos($name, '\\') + 1)); 42 | } 43 | 44 | $requirements = array('entityType' => implode('|', $simpleNames), 'id' => '\d+'); 45 | 46 | $getAllRoute = new Route('/{entityType}', array('_controller' => 'OutlandishRestBundle:Rest:getAll'), $requirements, array(), '', array(), array('GET')); 47 | $getOneRoute = new Route('/{entityType}/{id}', array('_controller' => 'OutlandishRestBundle:Rest:getOne'), $requirements, array(), '', array(), array('GET')); 48 | $postRoute = new Route('/{entityType}', array('_controller' => 'OutlandishRestBundle:Rest:post'), $requirements, array(), '', array(), array('POST')); 49 | $putRoute = new Route('/{entityType}/{id}', array('_controller' => 'OutlandishRestBundle:Rest:put'), $requirements, array(), '', array(), array('PUT')); 50 | $deleteRoute = new Route('/{entityType}/{id}', array('_controller' => 'OutlandishRestBundle:Rest:delete'), $requirements, array(), '', array(), array('DELETE')); 51 | 52 | $routes = new RouteCollection(); 53 | $routes->add('outlandish_rest.get_all', $getAllRoute); 54 | $routes->add('outlandish_rest.get_one', $getOneRoute); 55 | $routes->add('outlandish_rest.post', $postRoute); 56 | $routes->add('outlandish_rest.put', $putRoute); 57 | $routes->add('outlandish_rest.delete', $deleteRoute); 58 | 59 | $this->loaded = true; 60 | 61 | return $routes; 62 | } 63 | 64 | /** 65 | * @param mixed $resource A resource 66 | * @param string $type The resource type 67 | * @return Boolean true if this class supports the given resource, false otherwise 68 | */ 69 | public function supports($resource, $type = null) { 70 | return $type == 'outlandish_rest'; 71 | } 72 | 73 | public function getResolver() {} 74 | 75 | public function setResolver(LoaderResolverInterface $resolver) {} 76 | } -------------------------------------------------------------------------------- /Controller/RestController.php: -------------------------------------------------------------------------------- 1 | get('doctrine.orm.entity_manager')->getMetadataFactory()->getAllMetadata(); 26 | foreach ($meta as $m) { 27 | $fqcn = $m->getName(); 28 | $simpleName = Container::underscore(substr($fqcn, strrpos($fqcn, '\\') + 1)); 29 | if ($simpleName == $entityType) { 30 | return $fqcn; 31 | } 32 | } 33 | 34 | return null; 35 | } 36 | 37 | /** 38 | * Get array of all entities of type 39 | * 40 | * Supports simple queries with FIQL and pagination with 'offset' and 'per_page' 41 | */ 42 | public function getAllAction($entityType, Request $request) { 43 | $em = $this->get('doctrine.orm.entity_manager'); 44 | $serializer = $this->get('jms_serializer'); 45 | $this->get('jms_serializer.doctrine_proxy_subscriber')->setEnabled(false); 46 | $className = $this->getFQCN($entityType); 47 | 48 | //build basic DQL query 49 | $builder = $em->createQueryBuilder(); 50 | $builder->setFirstResult($request->query->get('offset', 0)); 51 | $defaultPageSize = 0; //todo: make this configurable 52 | $pageSize = $request->query->get('per_page', $defaultPageSize); 53 | if ($pageSize) { 54 | $builder->setMaxResults($pageSize); 55 | } 56 | $builder->select('e')->from($className, 'e'); 57 | $this->parseFIQL($request, $builder); 58 | 59 | $fastSerialization = false; //todo: make this configurable 60 | if ($fastSerialization) { 61 | 62 | $meta = $em->getClassMetadata($className); 63 | $associationMappings = $meta->getAssociationMappings(); 64 | $fieldNames = $meta->getFieldNames(); 65 | 66 | //explicit joins and selects for associated IDs 67 | foreach ($associationMappings as $assocName => $assoc) { 68 | if (isset($assoc['joinTable'])) { 69 | $builder->leftJoin('e.'.$assocName, $assocName.'Table'); 70 | $builder->add('select', "{$assocName}Table.id AS {$assocName}", true); 71 | } else { 72 | $builder->add('select', "IDENTITY(e.$assocName) AS $assocName", true); 73 | } 74 | } 75 | 76 | //fetch flat data 77 | $rows = $builder->getQuery()->execute(null, Query::HYDRATE_SCALAR); 78 | 79 | //unflatten rows with arrays of IDs for *-to-many associations 80 | $id = null; 81 | $data = array(); 82 | $entity = array(); 83 | foreach ($rows as $row) { 84 | if ($id != $row['e_id']) { 85 | //id has changed so is next row 86 | $data[] = $entity; 87 | $entity = array(); 88 | 89 | //copy normal field data 90 | foreach ($fieldNames as $fieldName) { 91 | $entity[$fieldName] = $row['e_'.$fieldName]; 92 | 93 | //serialize DateTime objects 94 | if ($entity[$fieldName] instanceof \DateTime) { 95 | $entity[$fieldName] = $entity[$fieldName]->format(\DateTime::ISO8601); 96 | } 97 | } 98 | $id = $entity['id']; 99 | } 100 | 101 | //copy associated fields 102 | foreach ($associationMappings as $assocName => $assoc) { 103 | if (isset($assoc['joinTable'])) { 104 | if (!isset($entity[$assocName])) $entity[$assocName] = array(); 105 | if ($row[$assocName] != null) $entity[$assocName][] = $row[$assocName]; 106 | } else { 107 | $entity[$assocName] = $row[$assocName]; 108 | } 109 | } 110 | 111 | } 112 | //add last item 113 | $data[] = $entity; 114 | //remove first (empty) item 115 | array_shift($data); 116 | 117 | $text = json_encode($data); 118 | } else { 119 | //normal serialization 120 | $entities = $builder->getQuery()->getResult(); 121 | $text = $serializer->serialize($entities, 'json'); 122 | } 123 | 124 | return new Response($text, 200, array('Content-type' => 'application/json')); 125 | } 126 | 127 | 128 | /** 129 | * Get single entity of type 130 | */ 131 | public function getOneAction($entityType, $id) { 132 | $em = $this->getDoctrine()->getManager(); 133 | $serializer = $this->get('jms_serializer'); 134 | $this->get('jms_serializer.doctrine_proxy_subscriber')->setEnabled(false); 135 | $className = $this->getFQCN($entityType); 136 | 137 | $entity = $em->getRepository($className)->find($id); 138 | 139 | if (!$entity) { 140 | $data = array(array('message' => 'Entity not found')); 141 | $code = 404; 142 | } else { 143 | $data = $entity; 144 | $code = 200; 145 | } 146 | 147 | $text = $serializer->serialize($data, 'json'); 148 | return new Response($text, $code, array('Content-type' => 'application/json')); 149 | } 150 | 151 | /** 152 | * Create new entity of type and return it 153 | */ 154 | public function postAction($entityType, Request $request) { 155 | $em = $this->getDoctrine()->getManager(); 156 | $serializer = $this->get('jms_serializer'); 157 | $validator = $this->get('validator'); 158 | $this->get('jms_serializer.doctrine_proxy_subscriber')->setEnabled(false); 159 | $className = $this->getFQCN($entityType); 160 | 161 | $entity = $serializer->deserialize($request->getContent(), $className, 'json'); 162 | $constraintViolations = $validator->validate($entity); 163 | 164 | if (count($constraintViolations)) { 165 | $data = $constraintViolations; 166 | $code = 400; 167 | } else { 168 | $em->persist($entity); 169 | $em->flush(); 170 | 171 | $data = $entity; 172 | $code = 201; 173 | } 174 | 175 | $text = $serializer->serialize($data, 'json'); 176 | return new Response($text, $code, array('Content-type' => 'application/json')); 177 | } 178 | 179 | /** 180 | * Update single entity of type and return it 181 | */ 182 | public function putAction($entityType, $id, Request $request) { 183 | $em = $this->getDoctrine()->getManager(); 184 | $serializer = $this->get('jms_serializer'); 185 | $validator = $this->get('validator'); 186 | $this->get('jms_serializer.doctrine_proxy_subscriber')->setEnabled(false); 187 | $className = $this->getFQCN($entityType); 188 | 189 | $entity = $serializer->deserialize($request->getContent(), $className, 'json'); 190 | $constraintViolations = $validator->validate($entity); 191 | 192 | if (count($constraintViolations)) { 193 | $data = $constraintViolations; 194 | $code = 400; 195 | } else { 196 | $entity->setId($id); 197 | $em->merge($entity); 198 | $em->flush(); 199 | 200 | $data = $entity; 201 | $code = 200; 202 | } 203 | 204 | //work around Doctrine detached entities issue with many-to-many associations 205 | $reloadedEntity = $em->getRepository($className)->find($id); 206 | $meta = $em->getClassMetadata($className); 207 | foreach ($meta->getAssociationMappings() as $mapping) { 208 | if ($mapping['type'] == ClassMetadataInfo::MANY_TO_MANY) { 209 | $reflection = $meta->getReflectionProperty($mapping['fieldName']); 210 | $reflection->setValue($reloadedEntity, $reflection->getValue($entity)); 211 | } 212 | } 213 | $em->flush(); 214 | 215 | $text = $serializer->serialize($data, 'json'); 216 | return new Response($text, $code, array('Content-type' => 'application/json')); 217 | } 218 | 219 | /** 220 | * Delete single entity of type 221 | */ 222 | public function deleteAction($entityType, $id) { 223 | $em = $this->getDoctrine()->getManager(); 224 | $className = $this->getFQCN($entityType); 225 | 226 | $entity = $em->getRepository($className)->find($id); 227 | 228 | if (!$entity) { 229 | $text = json_encode(array(array('message' => 'Entity not found'))); 230 | $code = 404; 231 | } else { 232 | $em->remove($entity); 233 | $em->flush(); 234 | 235 | $text = ''; 236 | $code = 204; 237 | } 238 | 239 | return new Response($text, $code, array('Content-type' => 'application/json')); 240 | } 241 | 242 | /** 243 | * Parse simple data queries such as foo=baz&bar=lt=10 244 | * 245 | * It's kind of a subset of FIQL http://cxf.apache.org/docs/jax-rs-search.html 246 | * 247 | * @param Request $request 248 | * @param QueryBuilder $builder 249 | */ 250 | protected function parseFIQL(Request $request, QueryBuilder $builder) { 251 | //allowed operators 252 | $operatorMap = array('' => '=', 'ne' => '!=', 'lt' => '<', 'gt' => '>', 'le' => '<=', 'ge' => '>='); 253 | 254 | //load class metadata 255 | $em = $builder->getEntityManager(); 256 | $classNames = $builder->getRootEntities(); 257 | $metadata = $em->getClassMetadata($classNames[0]); 258 | 259 | //use raw query string to allow for multiple instances of same param, e.g. foo=gt=1&foo=lt=3 260 | $queryParts = $_SERVER['QUERY_STRING'] ? explode('&', $_SERVER['QUERY_STRING']) : array(); 261 | 262 | //process query parts 263 | foreach ($queryParts as $index => $part) { 264 | list($name, $value) = explode('=', $part, 2); 265 | 266 | //default operator 267 | $operator = ''; 268 | 269 | //check for negation 270 | if (substr($name, -1) == '!') { 271 | $operator = 'ne'; 272 | $name = substr($name, 0, -1); 273 | } 274 | 275 | //convert query_property to queryProperty 276 | $camelName = Container::camelize($name); 277 | $camelName[0] = strtolower($camelName[0]); 278 | 279 | //check queried property exists 280 | if (!isset($metadata->columnNames[$camelName]) && !isset($metadata->associationMappings[$camelName])) { 281 | continue; 282 | } 283 | 284 | //look for explicit operator 285 | if (strpos($value, '=') !== false) { 286 | list($operator, $value) = explode('=', $value, 2); 287 | } 288 | 289 | //ensure operator is valid 290 | if (!isset($operatorMap[$operator])) { 291 | continue; 292 | } 293 | 294 | //add to query 295 | $builder->andWhere('e.'.$camelName . $operatorMap[$operator].':'.$name.$index); 296 | $builder->setParameter($name.$index, $value); 297 | } 298 | } 299 | } --------------------------------------------------------------------------------