first commit

This commit is contained in:
2025-06-17 11:53:18 +02:00
commit 9f0f7ba12b
8804 changed files with 1369176 additions and 0 deletions

View File

@ -0,0 +1,165 @@
<?php
namespace Negotiation;
use Negotiation\Exception\InvalidArgument;
use Negotiation\Exception\InvalidHeader;
abstract class AbstractNegotiator
{
/**
* @param string $header A string containing an `Accept|Accept-*` header.
* @param array $priorities A set of server priorities.
*
* @return AcceptHeader|null best matching type
*/
public function getBest($header, array $priorities, $strict = false)
{
if (empty($priorities)) {
throw new InvalidArgument('A set of server priorities should be given.');
}
if (!$header) {
throw new InvalidArgument('The header string should not be empty.');
}
// Once upon a time, two `array_map` calls were sitting there, but for
// some reasons, they triggered `E_WARNING` time to time (because of
// PHP bug [55416](https://bugs.php.net/bug.php?id=55416). Now, they
// are gone.
// See: https://github.com/willdurand/Negotiation/issues/81
$acceptedHeaders = array();
foreach ($this->parseHeader($header) as $h) {
try {
$acceptedHeaders[] = $this->acceptFactory($h);
} catch (Exception\Exception $e) {
if ($strict) {
throw $e;
}
}
}
$acceptedPriorities = array();
foreach ($priorities as $p) {
$acceptedPriorities[] = $this->acceptFactory($p);
}
$matches = $this->findMatches($acceptedHeaders, $acceptedPriorities);
$specificMatches = array_reduce($matches, 'Negotiation\AcceptMatch::reduce', []);
usort($specificMatches, 'Negotiation\AcceptMatch::compare');
$match = array_shift($specificMatches);
return null === $match ? null : $acceptedPriorities[$match->index];
}
/**
* @param string $header A string containing an `Accept|Accept-*` header.
*
* @return AcceptHeader[] An ordered list of accept header elements
*/
public function getOrderedElements($header)
{
if (!$header) {
throw new InvalidArgument('The header string should not be empty.');
}
$elements = array();
$orderKeys = array();
foreach ($this->parseHeader($header) as $key => $h) {
try {
$element = $this->acceptFactory($h);
$elements[] = $element;
$orderKeys[] = [$element->getQuality(), $key, $element->getValue()];
} catch (Exception\Exception $e) {
// silently skip in case of invalid headers coming in from a client
}
}
// sort based on quality and then original order. This is necessary as
// to ensure that the first in the list for two items with the same
// quality stays in that order in both PHP5 and PHP7.
uasort($orderKeys, function ($a, $b) {
$qA = $a[0];
$qB = $b[0];
if ($qA == $qB) {
return $a[1] <=> $b[1];
}
return ($qA > $qB) ? -1 : 1;
});
$orderedElements = [];
foreach ($orderKeys as $key) {
$orderedElements[] = $elements[$key[1]];
}
return $orderedElements;
}
/**
* @param string $header accept header part or server priority
*
* @return AcceptHeader Parsed header object
*/
abstract protected function acceptFactory($header);
/**
* @param AcceptHeader $header
* @param AcceptHeader $priority
* @param integer $index
*
* @return AcceptMatch|null Headers matched
*/
protected function match(AcceptHeader $header, AcceptHeader $priority, $index)
{
$ac = $header->getType();
$pc = $priority->getType();
$equal = !strcasecmp($ac, $pc);
if ($equal || $ac === '*') {
$score = 1 * $equal;
return new AcceptMatch($header->getQuality() * $priority->getQuality(), $score, $index);
}
return null;
}
/**
* @param string $header A string that contains an `Accept*` header.
*
* @return AcceptHeader[]
*/
private function parseHeader($header)
{
$res = preg_match_all('/(?:[^,"]*+(?:"[^"]*+")?)+[^,"]*+/', $header, $matches);
if (!$res) {
throw new InvalidHeader(sprintf('Failed to parse accept header: "%s"', $header));
}
return array_values(array_filter(array_map('trim', $matches[0])));
}
/**
* @param AcceptHeader[] $headerParts
* @param Priority[] $priorities Configured priorities
*
* @return AcceptMatch[] Headers matched
*/
private function findMatches(array $headerParts, array $priorities)
{
$matches = [];
foreach ($priorities as $index => $p) {
foreach ($headerParts as $h) {
if (null !== $match = $this->match($h, $p, $index)) {
$matches[] = $match;
}
}
}
return $matches;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Negotiation;
use Negotiation\Exception\InvalidMediaType;
final class Accept extends BaseAccept implements AcceptHeader
{
private $basePart;
private $subPart;
public function __construct($value)
{
parent::__construct($value);
if ($this->type === '*') {
$this->type = '*/*';
}
$parts = explode('/', $this->type);
if (count($parts) !== 2 || !$parts[0] || !$parts[1]) {
throw new InvalidMediaType();
}
$this->basePart = $parts[0];
$this->subPart = $parts[1];
}
/**
* @return string
*/
public function getSubPart()
{
return $this->subPart;
}
/**
* @return string
*/
public function getBasePart()
{
return $this->basePart;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation;
final class AcceptCharset extends BaseAccept implements AcceptHeader
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation;
final class AcceptEncoding extends BaseAccept implements AcceptHeader
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation;
interface AcceptHeader
{
}

View File

@ -0,0 +1,49 @@
<?php
namespace Negotiation;
use Negotiation\Exception\InvalidLanguage;
final class AcceptLanguage extends BaseAccept implements AcceptHeader
{
private $language;
private $script;
private $region;
public function __construct($value)
{
parent::__construct($value);
$parts = explode('-', $this->type);
if (2 === count($parts)) {
$this->language = $parts[0];
$this->region = $parts[1];
} elseif (1 === count($parts)) {
$this->language = $parts[0];
} elseif (3 === count($parts)) {
$this->language = $parts[0];
$this->script = $parts[1];
$this->region = $parts[2];
} else {
// TODO: this part is never reached...
throw new InvalidLanguage();
}
}
/**
* @return string
*/
public function getSubPart()
{
return $this->region;
}
/**
* @return string
*/
public function getBasePart()
{
return $this->language;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Negotiation;
final class AcceptMatch
{
/**
* @var float
*/
public $quality;
/**
* @var int
*/
public $score;
/**
* @var int
*/
public $index;
public function __construct($quality, $score, $index)
{
$this->quality = $quality;
$this->score = $score;
$this->index = $index;
}
/**
* @param AcceptMatch $a
* @param AcceptMatch $b
*
* @return int
*/
public static function compare(AcceptMatch $a, AcceptMatch $b)
{
if ($a->quality !== $b->quality) {
return $a->quality > $b->quality ? -1 : 1;
}
if ($a->index !== $b->index) {
return $a->index > $b->index ? 1 : -1;
}
return 0;
}
/**
* @param array $carry reduced array
* @param AcceptMatch $match match to be reduced
*
* @return AcceptMatch[]
*/
public static function reduce(array $carry, AcceptMatch $match)
{
if (!isset($carry[$match->index]) || $carry[$match->index]->score < $match->score) {
$carry[$match->index] = $match;
}
return $carry;
}
}

View File

@ -0,0 +1,158 @@
<?php
namespace Negotiation;
abstract class BaseAccept
{
/**
* @var float
*/
private $quality = 1.0;
/**
* @var string
*/
private $normalized;
/**
* @var string
*/
private $value;
/**
* @var array
*/
private $parameters;
/**
* @var string
*/
protected $type;
/**
* @param string $value
*/
public function __construct($value)
{
list($type, $parameters) = $this->parseParameters($value);
if (isset($parameters['q'])) {
$this->quality = (float) $parameters['q'];
unset($parameters['q']);
}
$type = trim(strtolower($type));
$this->value = $value;
$this->normalized = $type . ($parameters ? "; " . $this->buildParametersString($parameters) : '');
$this->type = $type;
$this->parameters = $parameters;
}
/**
* @return string
*/
public function getNormalizedValue()
{
return $this->normalized;
}
/**
* @return string
*/
public function getValue()
{
return $this->value;
}
/**
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* @return float
*/
public function getQuality()
{
return $this->quality;
}
/**
* @return array
*/
public function getParameters()
{
return $this->parameters;
}
/**
* @param string $key
* @param mixed $default
*
* @return string|null
*/
public function getParameter($key, $default = null)
{
return isset($this->parameters[$key]) ? $this->parameters[$key] : $default;
}
/**
* @param string $key
*
* @return boolean
*/
public function hasParameter($key)
{
return isset($this->parameters[$key]);
}
/**
*
* @param string|null $acceptPart
* @return array
*/
private function parseParameters($acceptPart)
{
if ($acceptPart === null) {
return ['', []];
}
$parts = explode(';', $acceptPart);
$type = array_shift($parts);
$parameters = [];
foreach ($parts as $part) {
$part = explode('=', $part);
if (2 !== count($part)) {
continue; // TODO: throw exception here?
}
$key = strtolower(trim($part[0])); // TODO: technically not allowed space around "=". throw exception?
$parameters[$key] = trim($part[1], ' "');
}
return [ $type, $parameters ];
}
/**
* @param string $parameters
*
* @return string
*/
private function buildParametersString($parameters)
{
$parts = [];
ksort($parameters);
foreach ($parameters as $key => $val) {
$parts[] = sprintf('%s=%s', $key, $val);
}
return implode('; ', $parts);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Negotiation;
class CharsetNegotiator extends AbstractNegotiator
{
/**
* {@inheritdoc}
*/
protected function acceptFactory($accept)
{
return new AcceptCharset($accept);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Negotiation;
class EncodingNegotiator extends AbstractNegotiator
{
/**
* {@inheritdoc}
*/
protected function acceptFactory($accept)
{
return new AcceptEncoding($accept);
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation\Exception;
interface Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation\Exception;
class InvalidArgument extends \InvalidArgumentException implements Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation\Exception;
class InvalidHeader extends \RuntimeException implements Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation\Exception;
class InvalidLanguage extends \RuntimeException implements Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Negotiation\Exception;
class InvalidMediaType extends \RuntimeException implements Exception
{
}

View File

@ -0,0 +1,41 @@
<?php
namespace Negotiation;
class LanguageNegotiator extends AbstractNegotiator
{
/**
* {@inheritdoc}
*/
protected function acceptFactory($accept)
{
return new AcceptLanguage($accept);
}
/**
* {@inheritdoc}
*/
protected function match(AcceptHeader $acceptLanguage, AcceptHeader $priority, $index)
{
if (!$acceptLanguage instanceof AcceptLanguage || !$priority instanceof AcceptLanguage) {
return null;
}
$ab = $acceptLanguage->getBasePart();
$pb = $priority->getBasePart();
$as = $acceptLanguage->getSubPart();
$ps = $priority->getSubPart();
$baseEqual = !strcasecmp((string)$ab, (string)$pb);
$subEqual = !strcasecmp((string)$as, (string)$ps);
if (($ab == '*' || $baseEqual) && ($as === null || $subEqual || null === $ps)) {
$score = 10 * $baseEqual + $subEqual;
return new AcceptMatch($acceptLanguage->getQuality() * $priority->getQuality(), $score, $index);
}
return null;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Negotiation;
class Negotiator extends AbstractNegotiator
{
/**
* {@inheritdoc}
*/
protected function acceptFactory($accept)
{
return new Accept($accept);
}
/**
* {@inheritdoc}
*/
protected function match(AcceptHeader $accept, AcceptHeader $priority, $index)
{
if (!$accept instanceof Accept || !$priority instanceof Accept) {
return null;
}
$acceptBase = $accept->getBasePart();
$priorityBase = $priority->getBasePart();
$acceptSub = $accept->getSubPart();
$prioritySub = $priority->getSubPart();
$intersection = array_intersect_assoc($accept->getParameters(), $priority->getParameters());
$baseEqual = !strcasecmp($acceptBase, $priorityBase);
$subEqual = !strcasecmp($acceptSub, $prioritySub);
if (($acceptBase === '*' || $baseEqual)
&& ($acceptSub === '*' || $subEqual)
&& count($intersection) === count($accept->getParameters())
) {
$score = 100 * $baseEqual + 10 * $subEqual + count($intersection);
return new AcceptMatch($accept->getQuality() * $priority->getQuality(), $score, $index);
}
if (!strstr($acceptSub, '+') || !strstr($prioritySub, '+')) {
return null;
}
// Handle "+" segment wildcards
list($acceptSub, $acceptPlus) = $this->splitSubPart($acceptSub);
list($prioritySub, $priorityPlus) = $this->splitSubPart($prioritySub);
// If no wildcards in either the subtype or + segment, do nothing.
if (!($acceptBase === '*' || $baseEqual)
|| !($acceptSub === '*' || $prioritySub === '*' || $acceptPlus === '*' || $priorityPlus === '*')
) {
return null;
}
$subEqual = !strcasecmp($acceptSub, $prioritySub);
$plusEqual = !strcasecmp($acceptPlus, $priorityPlus);
if (($acceptSub === '*' || $prioritySub === '*' || $subEqual)
&& ($acceptPlus === '*' || $priorityPlus === '*' || $plusEqual)
&& count($intersection) === count($accept->getParameters())
) {
$score = 100 * $baseEqual + 10 * $subEqual + $plusEqual + count($intersection);
return new AcceptMatch($accept->getQuality() * $priority->getQuality(), $score, $index);
}
return null;
}
/**
* Split a subpart into the subpart and "plus" part.
*
* For media-types of the form "application/vnd.example+json", matching
* should allow wildcards for either the portion before the "+" or
* after. This method splits the subpart to allow such matching.
*/
protected function splitSubPart($subPart)
{
if (!strstr($subPart, '+')) {
return [$subPart, ''];
}
return explode('+', $subPart, 2);
}
}