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,19 @@
Copyright (c) William Durand <will+git@drnd.me>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

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);
}
}