Skip to content

Instantly share code, notes, and snippets.

@zircote
Last active December 18, 2015 05:19
Show Gist options
  • Save zircote/5731445 to your computer and use it in GitHub Desktop.
Save zircote/5731445 to your computer and use it in GitHub Desktop.
A PHP Exception to provide the functionality of the `application/api-problem` RFC proposal. This class creates simple JSON [RFC4627] and XML [W3C.REC-xml-20081126] document formats to suit the purpose described in [Problem Details](http://tools.ietf.org/html/draft-nottingham-http-problem).

ApiProblem

ApiProblem is an attempt to provide the functionality and problem reporting as defined in Problem Details for HTTP APIs. The goal being a simple Exception wrapper for PHP that can send the desired response in JSON and XML.

Use

The sendHttpResponse method

The sendHTTPResponse has two parameter, both optional $format and $terminate.

  • format:
  • ApiProblem::FORMAT_XML: application/api-problem+json
  • ApiProblem::FORMAT_JSON: application/api-problem+xml
  • terminate: bool
  • true (default) headers are sent and execution is terminated.
  • false the body payload is returned

JSON Example

<?php
$apiProblem = new ApiProblem(
    'http://api-problem.domain.com/some-url.html',
    'Bad Request',
    400,
    'some detail',
    'http://domain.com/this-request'
);
$apiProblem->sendHTTPResponse(ApiProblem::FORMAT_JSON);

Result

HTTP/1.0 400 Bad Request
Access-Control-Allow-Origin: *
Content-Type: application/api-problem+json
Link: <http://api-problem.domain.com/some-url.html>; rel="http://api-problem.domain.com/some-url.html" title="Bad Request"

{
    "problemType": "http://api-problem.domain.com/some-url.html",
    "title": "Bad Request",
    "httpStatus": 400,
    "detail": "some detail",
    "problemInstance": "http://domain.com/this-request"
}

XML Example

<?php
try {
  throw new ApiProblem(
      'http://api-problem.domain.com/some-url.html',
      'Bad Request',
      400,
      'some detail',
      'http://domain.com/this-request'
  );
} catch (ApiProblem $e) {
  $e->sendHTTPResponse(ApiProblem::FORMAT_XML);
}

Result

HTTP/1.0 400 Bad Request
Access-Control-Allow-Origin: *
Content-Type: application/api-problem+xml
Link: <http://api-problem.domain.com/some-url.html>; rel="http://api-problem.domain.com/some-url.html" title="Bad Request"


<?xml version="1.0" encoding="UTF-8"?>
<problem xmlns="urn:ietf:draft:nottingham-http-problem">
    <problemType>http://api-problem.domain.com/some-url.html</problemType>
    <title>Bad Request</title>
    <httpStatus>400</httpStatus>
    <detail>some detail</detail>
    <problemInstance>http://domain.com/this-request</problemInstance>
</problem>
<?php
/**
* @link http://tools.ietf.org/html/draft-nottingham-http-problem
* @link http://tools.ietf.org/html/rfc5988
*
* @package ApiProblem
* @category Exception
*/
/**
* @package ApiProblem
* @category Exception
*/
class ApiProblem extends \Exception
{
/**
*
*/
const FORMAT_JSON = 'application/api-problem+json';
/**
*
*/
const FORMAT_XML = 'application/api-problem+xml';
/**
* @link http://tools.ietf.org/html/draft-nottingham-http-problem
*/
const XMLNS = 'urn:ietf:draft:nottingham-http-problem';
/**
* An absolute URI [RFC3986] that identifies the problem type. When dereferenced, it SHOULD provide human-readable
* documentation for the problem type (e.g., using HTML)
* @var string
*/
protected $problemType;
/**
* A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the
* problem, except for purposes of localisation.
* @var string
*/
protected $title;
/**
* The HTTP status code ([RFC2616], Section 6) generated by the origin server for this occurrence of the problem.
* (optional)
* @var number
*/
protected $httpStatus;
/**
* An human readable explanation specific to this occurrence of the problem.
* (optional)
* @var string
*/
protected $detail;
/**
* An absolute URI that identifies the specific occurrence of the problem. It may or may not yield further
* information if dereferenced.
* (optional)
* @var string
*/
protected $problemInstance;
/**
* @var array
*/
protected $extension = array();
/**
* @var \DOMDocument
*/
protected $xml;
/**
* @param string $problemType
* @param string $title
* @param null|number $httpStatus
* @param null|string $detail
* @param null|string $problemInstance
*/
public function __construct($problemType, $title, $httpStatus=null, $detail=null, $problemInstance=null)
{
$this->problemType = $problemType;
$this->title = $title;
$this->message = $title;
if ($httpStatus) {
$this->code = $httpStatus;
}
if ($httpStatus) {
$this->httpStatus = $httpStatus;
}
if ($detail) {
$this->detail = $detail;
}
if ($problemInstance) {
$this->problemInstance = $problemInstance;
}
}
/**
*
* @param string $detail
* @return ApiProblem
*/
public function setDetail($detail)
{
$this->detail = $detail;
return $this;
}
/**
* @return string
*/
public function getDetail()
{
return $this->detail;
}
/**
*
* @param array $extension
* @return ApiProblem
*/
public function setExtension($extension)
{
$this->extension = $extension;
return $this;
}
/**
* @return array
*/
public function getExtension()
{
return $this->extension;
}
/**
*
* @param number $httpStatus
* @return ApiProblem
*/
public function setHttpStatus($httpStatus)
{
$this->httpStatus = $httpStatus;
return $this;
}
/**
* @return number
*/
public function getHttpStatus()
{
return $this->httpStatus;
}
/**
*
* @param string $problemInstance
* @return ApiProblem
*/
public function setProblemInstance($problemInstance)
{
$this->problemInstance = $problemInstance;
return $this;
}
/**
* @return string
*/
public function getProblemInstance()
{
return $this->problemInstance;
}
/**
*
* @param string $problemType
* @return ApiProblem
*/
public function setProblemType($problemType)
{
$this->problemType = $problemType;
return $this;
}
/**
* @return string
*/
public function getProblemType()
{
return $this->problemType;
}
/**
*
* @param string $title
* @return ApiProblem
*/
public function setTitle($title)
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @param string $extensionKey
* @param null|string $extensionValue
*/
public function setExtensionData($extensionKey, $extensionValue)
{
if (!preg_match('/(^[a-z])/i', $extensionKey)) {
trigger_error('api-problem extension names must begin with an ALPHA character', E_ERROR);
}
if (!preg_match('/^([a-z0-9_]){1,}$/i', $extensionKey)) {
trigger_error('api-problem extension names must contain an "ALPHA", "DIGIT" or "_" character', E_ERROR);
}
if (!in_array($extensionKey, array('problemType','title','httpStatus','detail','problemInstance'))){
$this->extension[$extensionKey] = $extensionValue;
} else {
trigger_error('api-problem extension names MUST NOT share the same name as api-problem members', E_ERROR);
}
}
/**
* @return array
*/
protected function formatResult()
{
$firstClassMembers = array(
'problemType' => $this->problemType,
'title' => $this->title,
'httpStatus' => $this->httpStatus,
'detail' => $this->detail,
'problemInstance' => $this->problemInstance
);
return array_filter($firstClassMembers);
}
/**
* @return string
*/
public function toXml()
{
$this->xml = new \DOMDocument('1.0', 'UTF-8');
$problemElement = $this->xml->createElement('problem');
$problemElement->setAttribute('xmlns', self::XMLNS);
foreach ($this->formatResult() as $node => $nodeValue) {
$node = $problemElement->appendChild(new \DOMElement($node));
$node->appendChild($this->xml->createTextNode($nodeValue));
}
foreach ($this->extension as $extensionName => $extensionValue) {
$childNode = $this->appendXmlChildren($extensionName, $extensionValue);
$problemElement->appendChild($childNode);
}
$this->xml->appendChild($problemElement);
return $this->xml->saveXML();
}
/**
* @param $nodeName
* @param $nodeValue
* @return \DOMElement|\DOMText
*/
protected function appendXmlChildren($nodeName, $nodeValue)
{
if (is_array($nodeValue)) {
$thisNode = $this->xml->createElement($nodeName);
foreach ($nodeValue as $childName => $childValue) {
$childName = is_numeric($childName) ? 'i' : $childName;
$thisNode->appendChild($this->appendXmlChildren($childName, $childValue));
}
return $thisNode;
} else {
return $this->xml->createElement($nodeName, $nodeValue);
}
}
/**
* @return string
*/
public function toJson()
{
$result = $this->formatResult();
foreach ($this->getExtension() as $n => $v) {
$result[$n] = $v;
}
if (version_compare(PHP_VERSION, '5.4.0', '<')) {
$json = json_encode($result);
$json = preg_replace('/\\\//', null, $json);
} else {
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
}
return $json;
}
/**
* @link http://tools.ietf.org/html/rfc5988
* @return string
*/
protected function declareAndSetLink()
{
return sprintf('Link: <%s>; rel="%s"; title="%s"', $this->problemType, $this->problemType, $this->title);
}
/**
* @param string $format
* @param bool $terminate
* @return string
*/
public function sendHTTPResponse($format = self::FORMAT_JSON, $terminate = false)
{
switch ($format) {
case self::FORMAT_XML:
$body = $this->toXml();
break;
case self::FORMAT_JSON:
default:
$body = $this->toJson();
break;
}
$link = $this->declareAndSetLink();
$contentType = sprintf('Content-Type:%s', $format);
if ($terminate === true && (defined('PHPUNIT_TEST_ACTIVE') && !PHPUNIT_TEST_ACTIVE)) {
ob_clean();
if (!headers_sent()) {
header($contentType);
header($link);
}
exit($body);
} elseif (defined('PHPUNIT_TEST_ACTIVE') && PHPUNIT_TEST_ACTIVE) {
return $contentType . "\r\n" . $link . "\r\n\r\n" . $body;
} else {
return $body;
}
}
}
<?php
/**
* @package ApiProblem
* @category Test
*/
/**
* @package ApiProblem
* @category Test
*/
class ApiProblemTest extends \PHPUnit_Framework_TestCase
{
/**
* @var ApiProblem
*/
protected $fixture;
public function setup()
{
$this->fixture = new ApiProblem(
'http://api-problem.domain.com/some-url.html',
'It\'s Broken',
401,
'some detail',
'http://domain.com/this-request'
);
}
public function tearDown()
{
$this->fixture = null;
}
public function testSendResponseJson()
{
$expected =
'Content-Type:application/api-problem+json'
. "\r\n"
. 'Link: <http://api-problem.domain.com/some-url.html>; rel="http://api-problem.domain.com/some-url.html";'
. ' title="It\'s Broken"'
. "\r\n\r\n"
. '{"problemType":"http://api-problem.domain.com/some-url.html","title":"It\'s Broken","httpStatus":401,'
.'"detail":"some detail","problemInstance":"http://domain.com/this-request"}';
$actual = $this->fixture->sendHTTPResponse();
$this->assertEquals($expected, $actual);
}
public function testSendResponseXml()
{
$expected =
'Content-Type:application/api-problem+xml'
. "\r\n"
. 'Link: <http://api-problem.domain.com/some-url.html>; rel="http://api-problem.domain.com/some-url.html";'
. ' title="It\'s Broken"'
. "\r\n\r\n"
. '<?xml version="1.0" encoding="UTF-8"?>'
. "\n"
. '<problem xmlns="urn:ietf:draft:nottingham-http-problem"><problemType>http://api-problem.domain.com/some'
.'-url.html</problemType><title>It\'s Broken</title><httpStatus>401</httpStatus><detail>some detail</detail>'
.'<problemInstance>http://domain.com/this-request</problemInstance></problem>'
. "\n";
$actual = $this->fixture->sendHTTPResponse(ApiProblem::FORMAT_XML);
$this->assertEquals($expected, $actual);
}
public function testSendResponseExtensionsXml()
{
$expected =
'Content-Type:application/api-problem+xml'
. "\r\n"
. 'Link: <http://api-problem.domain.com/some-url.html>; rel="http://api-problem.domain.com/some-url.html";'
. ' title="It\'s Broken"'
. "\r\n\r\n"
. '<?xml version="1.0" encoding="UTF-8"?>'
. "\n"
. '<problem xmlns="urn:ietf:draft:nottingham-http-problem"><problemType>http://api-problem.domain.com/some'
.'-url.html</problemType><title>It\'s Broken</title><httpStatus>401</httpStatus><detail>some detail</detail>'
.'<problemInstance>http://domain.com/this-request</problemInstance><ext_test>etx_test_value</ext_test>'
.'<ext_test_array_i><a>a_d</a><b>b_d</b><c>c_d</c></ext_test_array_i>'
.'<ext_test_array_ni><i>a</i><i>b</i><i>c</i></ext_test_array_ni>'
.'</problem>'
. "\n";
$this->fixture->setExtensionData('ext_test', 'etx_test_value');
$this->fixture->setExtensionData('ext_test_array_i', array('a' => 'a_d', 'b' => 'b_d', 'c' => 'c_d'));
$this->fixture->setExtensionData('ext_test_array_ni', array('a', 'b', 'c'));
$actual = $this->fixture->sendHTTPResponse(ApiProblem::FORMAT_XML);
$this->assertEquals($expected, $actual);
}
public function testSendResponseExtensionsJson()
{
$expected =
'Content-Type:application/api-problem+json'
. "\r\n"
. 'Link: <http://api-problem.domain.com/some-url.html>; rel="http://api-problem.domain.com/some-url.html";'
. ' title="It\'s Broken"'
. "\r\n\r\n"
. '{"problemType":"http://api-problem.domain.com/some-url.html","title":"It\'s Broken","httpStatus":401,'
.'"detail":"some detail","problemInstance":"http://domain.com/this-request","ext_test":"etx_test_value",'.
'"ext_test_array_i":{"a":"a_d","b":"b_d","c":"c_d"},"ext_test_array_ni":["a","b","c"]}';
$this->fixture->setExtensionData('ext_test', 'etx_test_value');
$this->fixture->setExtensionData('ext_test_array_i', array('a' => 'a_d', 'b' => 'b_d', 'c' => 'c_d'));
$this->fixture->setExtensionData('ext_test_array_ni', array('a', 'b', 'c'));
$actual = $this->fixture->sendHTTPResponse(ApiProblem::FORMAT_JSON);
$this->assertEquals($expected, $actual);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment