<?php
/**
 * Zend Framework (http://framework.zend.com/)
 *
 * @link      http://github.com/zendframework/zf2 for the canonical source repository
 * @copyright Copyright (c) 2005-2013 Zend Technologies USA Inc. (http://www.zend.com)
 * @license   http://framework.zend.com/license/new-bsd New BSD License
 */
namespace Zend\Mvc\Controller;

use 
Zend\Http\Request as HttpRequest;
use 
Zend\Json\Json;
use 
Zend\Mvc\Exception;
use 
Zend\Mvc\MvcEvent;
use 
Zend\Stdlib\RequestInterface as Request;
use 
Zend\Stdlib\ResponseInterface as Response;

/**
 * Abstract RESTful controller
 */
abstract class AbstractRestfulController extends AbstractController
{

    const 
CONTENT_TYPE_JSON 'json';

    
/**
     * @var string
     */
    
protected $eventIdentifier __CLASS__;

    
/**
     * @var array
     */
    
protected $contentTypes = array(
        
self::CONTENT_TYPE_JSON => array(
            
'application/hal+json',
            
'application/json'
        
)
    );

    
/**
     * @var int From Zend\Json\Json
     */
    
protected $jsonDecodeType Json::TYPE_ARRAY;

    
/**
     * Map of custom HTTP methods and their handlers
     *
     * @var array
     */
    
protected $customHttpMethodsMap = array();

    
/**
     * Create a new resource
     *
     * @param  mixed $data
     * @return mixed
     */
    
abstract public function create($data);

    
/**
     * Delete an existing resource
     *
     * @param  mixed $id
     * @return mixed
     */
    
abstract public function delete($id);

    
/**
     * Delete the entire resource collection
     *
     * Not marked as abstract, as that would introduce a BC break
     * (introduced in 2.1.0); instead, raises an exception if not implemented.
     *
     * @return mixed
     * @throws Exception\RuntimeException
     */
    
public function deleteList()
    {
        throw new 
Exception\RuntimeException(sprintf(
            
'%s is unimplemented'__METHOD__
        
));
    }

    
/**
     * Return single resource
     *
     * @param  mixed $id
     * @return mixed
     */
    
abstract public function get($id);

    
/**
     * Return list of resources
     *
     * @return mixed
     */
    
abstract public function getList();

    
/**
     * Retrieve HEAD metadata for the resource
     *
     * Not marked as abstract, as that would introduce a BC break
     * (introduced in 2.1.0); instead, raises an exception if not implemented.
     *
     * @param  null|mixed $id
     * @return mixed
     * @throws Exception\RuntimeException
     */
    
public function head($id null)
    {
        throw new 
Exception\RuntimeException(sprintf(
            
'%s is unimplemented'__METHOD__
        
));
    }

    
/**
     * Respond to the OPTIONS method
     *
     * Typically, set the Allow header with allowed HTTP methods, and
     * return the response.
     *
     * Not marked as abstract, as that would introduce a BC break
     * (introduced in 2.1.0); instead, raises an exception if not implemented.
     *
     * @return mixed
     * @throws Exception\RuntimeException
     */
    
public function options()
    {
        throw new 
Exception\RuntimeException(sprintf(
            
'%s is unimplemented'__METHOD__
        
));
    }

    
/**
     * Respond to the PATCH method
     *
     * Not marked as abstract, as that would introduce a BC break
     * (introduced in 2.1.0); instead, raises an exception if not implemented.
     *
     * @return mixed
     * @throws Exception\RuntimeException
     */
    
public function patch($id$data)
    {
        throw new 
Exception\RuntimeException(sprintf(
            
'%s is unimplemented'__METHOD__
        
));
    }

    
/**
     * Replace an entire resource collection
     *
     * Not marked as abstract, as that would introduce a BC break
     * (introduced in 2.1.0); instead, raises an exception if not implemented.
     *
     * @param  mixed $data
     * @return mixed
     * @throws Exception\RuntimeException
     */
    
public function replaceList($data)
    {
        throw new 
Exception\RuntimeException(sprintf(
            
'%s is unimplemented'__METHOD__
        
));
    }

    
/**
     * Update an existing resource
     *
     * @param  mixed $id
     * @param  mixed $data
     * @return mixed
     */
    
abstract public function update($id$data);

    
/**
     * Basic functionality for when a page is not available
     *
     * @return array
     */
    
public function notFoundAction()
    {
        
$this->response->setStatusCode(404);

        return array(
            
'content' => 'Page not found'
        
);
    }

    
/**
     * Dispatch a request
     *
     * If the route match includes an "action" key, then this acts basically like
     * a standard action controller. Otherwise, it introspects the HTTP method
     * to determine how to handle the request, and which method to delegate to.
     *
     * @events dispatch.pre, dispatch.post
     * @param  Request $request
     * @param  null|Response $response
     * @return mixed|Response
     * @throws Exception\InvalidArgumentException
     */
    
public function dispatch(Request $requestResponse $response null)
    {
        if (! 
$request instanceof HttpRequest) {
            throw new 
Exception\InvalidArgumentException(
                    
'Expected an HTTP request');
        }

        return 
parent::dispatch($request$response);
    }

    
/**
     * Handle the request
     *
     * @param  MvcEvent $e
     * @return mixed
     * @throws Exception\DomainException if no route matches in event or invalid HTTP method
     */
    
public function onDispatch(MvcEvent $e)
    {
        
$routeMatch $e->getRouteMatch();
        if (! 
$routeMatch) {
            
/**
             * @todo Determine requirements for when route match is missing.
             *       Potentially allow pulling directly from request metadata?
             */
            
throw new Exception\DomainException(
                    
'Missing route matches; unsure how to retrieve action');
        }

        
$request $e->getRequest();

        
// Was an "action" requested?
        
$action  $routeMatch->getParam('action'false);
        if (
$action) {
            
// Handle arbitrary methods, ending in Action
            
$method = static::getMethodFromAction($action);
            if (! 
method_exists($this$method)) {
                
$method 'notFoundAction';
            }
            
$return $this->$method();
            
$e->setResult($return);
            return 
$return;
        }

        
// RESTful methods
        
$method strtolower($request->getMethod());
        switch (
$method) {
            
// Custom HTTP methods (or custom overrides for standard methods)
            
case (isset($this->customHttpMethodsMap[$method])):
                
$callable $this->customHttpMethodsMap[$method];
                
$action $method;
                
$return call_user_func($callable$e);
                break;
            
// DELETE
            
case 'delete':
                
$id $this->getIdentifier($routeMatch$request);
                if (
$id !== false) {
                    
$action 'delete';
                    
$return $this->delete($id);
                    break;
                }

                
$action 'deleteList';
                
$return $this->deleteList();
                break;
            
// GET
            
case 'get':
                
$id $this->getIdentifier($routeMatch$request);
                if (
$id !== false) {
                    
$action 'get';
                    
$return $this->get($id);
                    break;
                }
                
$action 'getList';
                
$return $this->getList();
                break;
            
// HEAD
            
case 'head':
                
$id $this->getIdentifier($routeMatch$request);
                if (
$id === false) {
                    
$id null;
                }
                
$action 'head';
                
$this->head($id);
                
$response $e->getResponse();
                
$response->setContent('');
                
$return $response;
                break;
            
// OPTIONS
            
case 'options':
                
$action 'options';
                
$this->options();
                
$return $e->getResponse();
                break;
            
// PATCH
            
case 'patch':
                
$id $this->getIdentifier($routeMatch$request);
                if (
$id === false) {
                    
$response $e->getResponse();
                    
$response->setStatusCode(405);
                    return 
$response;
                }
                
$data   $this->processBodyContent($request);
                
$action 'patch';
                
$return $this->patch($id$data);
                break;
            
// POST
            
case 'post':
                
$action 'create';
                
$return $this->processPostData($request);
                break;
            
// PUT
            
case 'put':
                
$id   $this->getIdentifier($routeMatch$request);
                
$data $this->processBodyContent($request);

                if (
$id !== false) {
                    
$action 'update';
                    
$return $this->update($id$data);
                    break;
                }

                
$action 'replaceList';
                
$return $this->replaceList($data);
                break;
            
// All others...
            
default:
                
$response $e->getResponse();
                
$response->setStatusCode(405);
                return 
$response;
        }

        
$routeMatch->setParam('action'$action);
        
$e->setResult($return);
        return 
$return;
    }

    
/**
     * Process post data and call create
     *
     * @param Request $request
     * @return mixed
     */
    
public function processPostData(Request $request)
    {
        if (
$this->requestHasContentType($requestself::CONTENT_TYPE_JSON)) {
            
$data Json::decode($request->getContent(), $this->jsonDecodeType);
        } else {
            
$data $request->getPost()->toArray();
        }

        return 
$this->create($data);
    }

    
/**
     * Check if request has certain content type
     *
     * @param  Request $request
     * @param  string|null $contentType
     * @return bool
     */
    
public function requestHasContentType(Request $request$contentType '')
    {
        
/** @var $headerContentType \Zend\Http\Header\ContentType */
        
$headerContentType $request->getHeaders()->get('content-type');
        if (!
$headerContentType) {
            return 
false;
        }

        
$requestedContentType $headerContentType->getFieldValue();
        if (
strstr($requestedContentType';')) {
            
$headerData explode(';'$requestedContentType);
            
$requestedContentType array_shift($headerData);
        }
        
$requestedContentType trim($requestedContentType);
        if (
array_key_exists($contentType$this->contentTypes)) {
            foreach (
$this->contentTypes[$contentType] as $contentTypeValue) {
                if (
stripos($contentTypeValue$requestedContentType) === 0) {
                    return 
true;
                }
            }
        }

        return 
false;
    }

    
/**
     * Register a handler for a custom HTTP method
     *
     * This method allows you to handle arbitrary HTTP method types, mapping
     * them to callables. Typically, these will be methods of the controller
     * instance: e.g., array($this, 'foobar'). The typical place to register
     * these is in your constructor.
     *
     * Additionally, as this map is checked prior to testing the standard HTTP
     * methods, this is a way to override what methods will handle the standard
     * HTTP methods. However, if you do this, you will have to retrieve the
     * identifier and any request content manually.
     *
     * Callbacks will be passed the current MvcEvent instance.
     *
     * To retrieve the identifier, you can use "$id =
     * $this->getIdentifier($routeMatch, $request)",
     * passing the appropriate objects.
     *
     * To retrive the body content data, use "$data = $this->processBodyContent($request)";
     * that method will return a string, array, or, in the case of JSON, an object.
     *
     * @param  string $method
     * @param  Callable $handler
     * @return AbstractRestfulController
     */
    
public function addHttpMethodHandler($method/* Callable */ $handler)
    {
        if (!
is_callable($handler)) {
            throw new 
Exception\InvalidArgumentException(sprintf(
                
'Invalid HTTP method handler: must be a callable; received "%s"',
                (
is_object($handler) ? get_class($handler) : gettype($handler))
            ));
        }
        
$method strtolower($method);
        
$this->customHttpMethodsMap[$method] = $handler;
        return 
$this;
    }

    
/**
     * Retrieve the identifier, if any
     *
     * Attempts to see if an identifier was passed in either the URI or the
     * query string, returning it if found. Otherwise, returns a boolean false.
     *
     * @param  \Zend\Mvc\Router\RouteMatch $routeMatch
     * @param  Request $request
     * @return false|mixed
     */
    
protected function getIdentifier($routeMatch$request)
    {
        
$id $routeMatch->getParam('id'false);
        if (
$id) {
            return 
$id;
        }

        
$id $request->getQuery()->get('id'false);
        if (
$id) {
            return 
$id;
        }

        return 
false;
    }

    
/**
     * Process the raw body content
     *
     * If the content-type indicates a JSON payload, the payload is immediately
     * decoded and the data returned. Otherwise, the data is passed to
     * parse_str(). If that function returns a single-member array with a key
     * of "0", the method assumes that we have non-urlencoded content and
     * returns the raw content; otherwise, the array created is returned.
     *
     * @param  mixed $request
     * @return object|string|array
     */
    
protected function processBodyContent($request)
    {
        
$content $request->getContent();

        
// JSON content? decode and return it.
        
if ($this->requestHasContentType($requestself::CONTENT_TYPE_JSON)) {
            return 
Json::decode($content$this->jsonDecodeType);
        }

        
parse_str($content$parsedParams);

        
// If parse_str fails to decode, or we have a single element with key
        // 0, return the raw content.
        
if (!is_array($parsedParams)
            || (
== count($parsedParams) && isset($parsedParams[0]))
        ) {
            return 
$content;
        }

        return 
$parsedParams;
    }
}