Skip to content

Instantly share code, notes, and snippets.

@DaveRandom
Last active December 20, 2015 06:59
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DaveRandom/6089944 to your computer and use it in GitHub Desktop.
Save DaveRandom/6089944 to your computer and use it in GitHub Desktop.
PHP Content-Type negotiation
<?php
/**
* Represents a MIME content type
*
* @author Chris Wright <github@daverandom.com>
*/
class ContentType
{
/**
* @var string
*/
private $superType;
/**
* @var string
*/
private $subType;
/**
* @var string[] Associative array of parameters
*/
private $params;
/**
* @var float
*/
private $qValue;
/**
* Constructor
*
* @param string $superType
* @param string $subType
* @param string[] $params
* @param float $qValue
*/
public function __construct($superType, $subType, array $params = [], $qValue = 1)
{
$this->superType = $superType;
$this->subType = $subType;
$this->params = $params;
$this->qValue = $qValue;
}
/**
* Get a match score against another content type
*
* Returns a value between 0 and 1, where 1 is an exact match and 0 is no match
*
* @param ContentType
* @return float
*/
public function match(ContentType $contentType)
{
$score = 0;
$divisor = 2;
if ($this->superType === '*' || $contentType->getSuperType() === $this->superType) {
$score++;
}
if ($this->subType === '*' || $contentType->getSubType() === $this->subType) {
$score++;
}
$params = $contentType->getParams();
$divisor += count($params);
foreach ($this->params as $key => $value) {
if (isset($params[$key])) {
if ($params[$key] === $value) {
$score++;
}
} else {
$divisor++;
}
}
return $score / $divisor;
}
/**
* Get the string representation of this content type
*
* @return string
*/
public function __toString()
{
if ($this->params) {
$params = [];
foreach ($this->params as $key => $val) {
$params[] = $key . '=' . $val;
}
$params = ';' . implode(';', $params)
} else {
$params = '';
}
return $this->getType() . $params;
}
/**
* Get the string representation without parameters
*
* @return string
*/
public function getType()
{
return $this->superType . '/' . $this->subType;
}
/**
* Get the super type
*
* @return string
*/
public function getSuperType()
{
return $this->superType;
}
/**
* Get the sub type
*
* @return string
*/
public function getSubType()
{
return $this->subType;
}
/**
* Get the super type of this content type
*
* @return string
*/
public function getQValue()
{
return $this->qValue;
}
/**
* Get the value of a parameter by name
*
* Returns NULL when the named parameter does not exist
*
* @return string|null
*/
public function getParam($name)
{
return isset($this->params[$name]) ? $this->params[$name] : null;
}
/**
* Get all parameters as an associative array
*
* @return string[]
*/
public function getParams()
{
return $this->params;
}
}
<?php
/**
* Builds ContentType objects from strings
*
* @author Chris Wright <github@daverandom.com>
*/
class ContentTypeBuilder
{
/**
* @var ContentTypeFactory
*/
private $contentTypeFactory;
/**
* Constructor
*
* @param ContentTypeFactory $contentTypeFactory
*/
public function __construct(ContentTypeFactory $contentTypeFactory)
{
$this->contentTypeFactory = $contentTypeFactory;
}
/**
* Parse a string into a ContentType object
*
* @param string $typeDef
* @return ContentType
*/
public function build($typeDef)
{
$parts = preg_split('#\s*;\s*#', trim($typeDef), -1, PREG_SPLIT_NO_EMPTY);
$typeParts = preg_split('#\s*/\s*#', strtolower(array_shift($parts)), 2);
if (!isset($typeParts[1])) {
return null;
}
list($superType, $subType) = $typeParts;
if ($superType === '*' && $subType !== '*') {
return null;
}
$params = [];
$qValue = 1;
foreach ($parts as $param) {
$paramParts = preg_split('#\s*=\s*#', $param, 2);
if (isset($paramParts[1])) {
if ($paramParts[0] === 'q') {
$qValue = (float) $paramParts[1];
break; // TODO: we don't account for accept-extensions
// It has been suggested by @rdlowrey that this should be a permanent state
// of affairs because extensions are a bad idea and I'm inclined to agree
}
$params[$paramParts[0]] = $paramParts[1];
}
}
return $this->contentTypeFactory->create($superType, $subType, $params, $qValue);
}
}
<?php
/**
* Creates ContentType objects
*
* @author Chris Wright <github@daverandom.com>
*/
class ContentTypeFactory
{
/**
* Factory method
*
* @param string $superType
* @param string $subType
* @param string[] $params
* @param float $qValue
* @return ContentType
*/
public function create($superType, $subType, array $params = [], $qValue = 1)
{
return new ContentType($superType, $subType, $params, $qValue);
}
}
<?php
/**
* Reconciles acceptable type lists against available type lists
*
* @author Chris Wright <github@daverandom.com>
*/
class ContentTypeResolver
{
/**
* @var ContentTypeBuilder
*/
private $contentTypeBuilder;
/**
* Constructor
*
* @param ContentTypeBuilder $contentTypeBuilder
*/
public function __construct(ContentTypeBuilder $contentTypeBuilder)
{
$this->contentTypeBuilder = $contentTypeBuilder;
}
/**
* Parses an acceptable type list into an array of ContentType objects
*
* @param string $acceptedTypes
* @return ContentType[]
*/
private function parseAcceptedTypes($acceptedTypes)
{
$result = [];
foreach (explode(',', $acceptedTypes) as $typeSpec) {
if ($contentType = $this->contentTypeBuilder->build($typeSpec)) {
$result[$contentType->getSuperType()][$contentType->getSubType()][] = $contentType;
}
}
return $result;
}
/**
* Normalise a type spec to a ContentType object
*
* @param string|ContentType $spec
* @return ContentType
*/
private function getParsedType($spec)
{
return $spec instanceof ContentType ? $spec : $this->contentTypeBuilder->build((string) $spec);
}
/**
* Get the best matching accepted type from the list of available types
*
* @param string $acceptedTypes Type specification as specified in an HTTP Accept: header
* @param array $availableTypes List of available types, as strings or ContentType objects
* @return ContentType
*/
public function getResponseType($acceptedTypes, array $availableTypes)
{
if (!$acceptedTypes = $this->parseAcceptedTypes($acceptedTypes)) {
return $this->getParsedType(current($availableTypes));
}
$weightMap = [];
foreach ($availableTypes as $typeSpec) {
$availableType = $this->getParsedType($typeSpec);
$superType = $availableType->getSuperType();
$subType = $availableType->getSubType();
if (isset($acceptedTypes[$superType][$subType])) {
$candidates = $acceptedTypes[$superType][$subType];
} else if (isset($acceptedTypes[$superType]['*'])) {
$candidates = $acceptedTypes[$superType]['*'];
} else if (isset($acceptedTypes['*']['*'])) {
$candidates = $acceptedTypes['*']['*'];
} else {
continue;
}
$bestMatchFactor = 0;
$bestMatch = null;
foreach ($candidates as $acceptedType) {
$factor = $acceptedType->match($availableType);
if ($factor > $bestMatchFactor) {
$bestMatchFactor = $factor;
$bestMatch = $acceptedType;
if ($bestMatchFactor >= 1) {
break;
}
}
}
$weightMap[(string) $bestMatch->getQValue()][] = $availableType;
}
if (!$weightMap) {
return null;
}
ksort($weightMap, SORT_NUMERIC);
return end($weightMap)[0];
}
}
<?php
require 'ContentTypeResolver.php';
require 'ContentTypeBuilder.php';
require 'ContentTypeFactory.php';
require 'ContentType.php';
$resolver = new ContentTypeResolver(new ContentTypeBuilder(new ContentTypeFactory));
// Example from RFC 2616
$acceptHeader = 'text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5';
$availableTypes = [
'text/html',
'text/plain',
'text/html;level=1',
'image/jpeg',
'text/html;level=2',
'text/html;level=3',
];
echo $resolver->getResponseType($acceptHeader, $availableTypes);
@PeeHaa
Copy link

PeeHaa commented Feb 5, 2014

wow such gist great timing nice headers

@DaveRandom
Copy link
Author

@PeeHaa added some docblocks and generally made this presentable.

It's a long time since I wrote this but I'm pretty sure it works fairly well, the main logic is in ContentTypeResolver::getResponseType(), all the rest is just fluff to turn content types into sexy objects :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment