├── LICENSE ├── README.md ├── composer.json └── src └── Mimeparse.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Joe Gregorio 2 | Copyright (c) 2012-2025 Ben Ramsey 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

bitworking/mimeparse

2 | 3 |

4 | Basic functions for handling mime-types 5 |

6 | 7 |

8 | Source Code 9 | Download Package 10 | PHP Programming Language 11 | Read License 12 | Build Status 13 | Codecov Code Coverage 14 |

15 | 16 | ## About 17 | 18 | This library provides basic functionality for parsing mime-types names and matching 19 | them against a list of media-ranges. See 20 | [RFC 9110, section 5.3.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1) 21 | for a complete explanation. More information on the library can be found in the 22 | XML.com article "[Just use Media Types?](http://www.xml.com/pub/a/2005/06/08/restful.html)" 23 | 24 | This library was forked from the [original mimeparse library](https://github.com/conneg/mimeparse) 25 | on Google Project Hosting. The `Bitworking` namespace is a nod to original author 26 | [Joe Gregorio](https://bitworking.org/). 27 | 28 | This project adheres to a [code of conduct](CODE_OF_CONDUCT.md). By participating 29 | in this project and its community, you are expected to uphold this code. 30 | 31 | ## Installation 32 | 33 | Install this package as a dependency using [Composer](https://getcomposer.org). 34 | 35 | ``` bash 36 | composer require bitworking/mimeparse 37 | ``` 38 | 39 | ## Usage 40 | 41 | Use Mimeparse to specify a list of media types your application supports and 42 | compare that to the list of media types the user agent accepts (via the 43 | [HTTP Accept](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1) header; 44 | `$_SERVER['HTTP_ACCEPT']`). Mimeparse will give you the best match to send back 45 | to the user agent for your list of supported types or `null` if there is no best 46 | match. 47 | 48 | ``` php 49 | $supportedTypes = ['application/xbel+xml', 'text/xml']; 50 | $httpAcceptHeader = 'text/*;q=0.5,*/*; q=0.1'; 51 | 52 | $mimeType = \Bitworking\Mimeparse::bestMatch($supportedTypes, $httpAcceptHeader); 53 | 54 | echo $mimeType; // Should echo "text/xml" 55 | ``` 56 | 57 | You may also use Mimeparse to get the quality value of a specific media type 58 | when compared against a range of media types (from the `Accept` header, for 59 | example). 60 | 61 | ``` php 62 | $httpAcceptHeader = 'text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, *\/*;q=0.5'; 63 | 64 | $quality = \Bitworking\Mimeparse::quality('text/html', $httpAcceptHeader); 65 | 66 | echo $quality; // Should echo 0.7 67 | ``` 68 | 69 | ## Contributing 70 | 71 | Contributions are welcome! To contribute, please familiarize yourself with 72 | [CONTRIBUTING.md](CONTRIBUTING.md). 73 | 74 | ## Coordinated Disclosure 75 | 76 | Keeping user information safe and secure is a top priority, and we welcome the 77 | contribution of external security researchers. If you believe you've found a 78 | security issue in software that is maintained in this repository, please read 79 | [SECURITY.md](SECURITY.md) for instructions on submitting a vulnerability report. 80 | 81 | ## Copyright and License 82 | 83 | bitworking/mimeparse is copyright © [Ben Ramsey](https://ben.ramsey.dev) 84 | and licensed for use under the terms of the MIT License (MIT). 85 | 86 | The original mimeparse.php library is copyright © [Joe Gregorio](https://bitworking.org/) 87 | and licensed for use under the terms of the MIT License (MIT). 88 | 89 | Please see [LICENSE](LICENSE) for more information. 90 | 91 | [http-accept]: https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1 92 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitworking/mimeparse", 3 | "description": "Basic functions for handling mime-types.", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "accept", 8 | "conneg", 9 | "content negotiation", 10 | "http", 11 | "mime" 12 | ], 13 | "authors": [ 14 | { 15 | "name": "Joe Gregorio", 16 | "homepage": "https://bitworking.org" 17 | }, 18 | { 19 | "name": "Andrew \"Venom\" K." 20 | }, 21 | { 22 | "name": "Ben Ramsey", 23 | "email": "ben@ramsey.dev", 24 | "homepage": "https://ben.ramsey.dev" 25 | } 26 | ], 27 | "require": { 28 | "php": "^7.4 || ^8.0" 29 | }, 30 | "require-dev": { 31 | "captainhook/captainhook": "^5.18", 32 | "captainhook/plugin-composer": "^5.3", 33 | "ergebnis/composer-normalize": "^2.45", 34 | "php-parallel-lint/php-console-highlighter": "^1.0", 35 | "php-parallel-lint/php-parallel-lint": "^1.4", 36 | "phpstan/extension-installer": "^1.4", 37 | "phpstan/phpstan": "^2.1", 38 | "phpstan/phpstan-phpunit": "^2.0", 39 | "phpunit/phpunit": "^9 || ^10", 40 | "ramsey/coding-standard": "^2.1", 41 | "ramsey/conventional-commits": "^1.4", 42 | "roave/security-advisories": "dev-latest" 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true, 46 | "autoload": { 47 | "psr-4": { 48 | "Bitworking\\": "src/" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Bitworking\\Test\\": "tests/" 54 | } 55 | }, 56 | "config": { 57 | "allow-plugins": { 58 | "captainhook/plugin-composer": true, 59 | "dealerdirect/phpcodesniffer-composer-installer": true, 60 | "ergebnis/composer-normalize": true, 61 | "php-http/discovery": true, 62 | "phpstan/extension-installer": true, 63 | "ramsey/composer-repl": true 64 | }, 65 | "sort-packages": true 66 | }, 67 | "extra": { 68 | "captainhook": { 69 | "force-install": true 70 | }, 71 | "ramsey/conventional-commits": { 72 | "configFile": "conventional-commits.json" 73 | } 74 | }, 75 | "scripts": { 76 | "dev:analyze": [ 77 | "@dev:analyze:phpstan" 78 | ], 79 | "dev:analyze:phpstan": "phpstan analyse --ansi", 80 | "dev:build:clean": "git clean -fX build/", 81 | "dev:lint": [ 82 | "@dev:lint:syntax", 83 | "@dev:lint:style" 84 | ], 85 | "dev:lint:fix": "phpcbf", 86 | "dev:lint:style": "phpcs --colors", 87 | "dev:lint:syntax": "parallel-lint --colors src/ tests/", 88 | "dev:test": [ 89 | "@dev:lint", 90 | "@dev:analyze", 91 | "@dev:test:unit" 92 | ], 93 | "dev:test:coverage:ci": "@php -d 'xdebug.mode=coverage' vendor/bin/phpunit --colors=always --coverage-text --coverage-clover build/coverage/clover.xml --coverage-cobertura build/coverage/cobertura.xml --coverage-crap4j build/coverage/crap4j.xml --coverage-xml build/coverage/coverage-xml --log-junit build/junit.xml", 94 | "dev:test:coverage:html": "@php -d 'xdebug.mode=coverage' vendor/bin/phpunit --colors=always --coverage-html build/coverage/coverage-html/", 95 | "dev:test:unit": "phpunit --colors=always", 96 | "test": "@dev:test" 97 | }, 98 | "scripts-descriptions": { 99 | "dev:analyze": "Runs all static analysis checks.", 100 | "dev:analyze:phpstan": "Runs the PHPStan static analyzer.", 101 | "dev:build:clean": "Cleans the build/ directory.", 102 | "dev:lint": "Runs all linting checks.", 103 | "dev:lint:fix": "Auto-fixes coding standards issues, if possible.", 104 | "dev:lint:style": "Checks for coding standards issues.", 105 | "dev:lint:syntax": "Checks for syntax errors.", 106 | "dev:test": "Runs linting, static analysis, and unit tests.", 107 | "dev:test:coverage:ci": "Runs unit tests and generates CI coverage reports.", 108 | "dev:test:coverage:html": "Runs unit tests and generates HTML coverage report.", 109 | "dev:test:unit": "Runs unit tests.", 110 | "test": "Runs linting, static analysis, and unit tests." 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Mimeparse.php: -------------------------------------------------------------------------------- 1 | 8 | * @license https://opensource.org/license/mit/ MIT License 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace Bitworking; 14 | 15 | use UnexpectedValueException; 16 | 17 | use function array_multisort; 18 | use function array_pop; 19 | use function explode; 20 | use function floatval; 21 | use function is_numeric; 22 | use function str_contains; 23 | use function strpos; 24 | use function substr; 25 | use function trim; 26 | 27 | class Mimeparse 28 | { 29 | /** 30 | * Parses a media-range and returns an array with its components. 31 | * 32 | * The returned array contains: 33 | * 34 | * 1. type: The type categorization. 35 | * 2. subtype: The subtype categorization. 36 | * 3. params: An associative array of all the parameters for the media-range. 37 | * 4. generic subtype: A more generic subtype, if one is present. See 38 | * {@link https://www.rfc-editor.org/rfc/rfc6838#section-4.2.8 RFC 6838, section 4.2.8}. 39 | * 40 | * For example, the media-range `"application/xhtml+xml;q=0.5"` would get 41 | * parsed into: 42 | * 43 | * ``` 44 | * [ 45 | * "application", 46 | * "xhtml+xml", 47 | * [ 48 | * "q" => "0.5", 49 | * ], 50 | * "xml", 51 | * ] 52 | * ``` 53 | * 54 | * @return array{string, string, array, string} 55 | * 56 | * @throws UnexpectedValueException when `$mediaRange` does not include a valid subtype 57 | */ 58 | public static function parseMediaRange(string $mediaRange): array 59 | { 60 | $parts = explode(';', $mediaRange); 61 | 62 | $params = []; 63 | foreach ($parts as $param) { 64 | if (str_contains($param, '=')) { 65 | [$k, $v] = explode('=', trim($param)); 66 | $params[$k] = $v; 67 | } 68 | } 69 | 70 | $fullType = trim($parts[0]); 71 | 72 | // Java URLConnection class sends an Accept header that includes a 73 | // single "*". Turn it into a legal wildcard. 74 | if ($fullType === '*') { 75 | $fullType = '*/*'; 76 | } 77 | 78 | [$type, $subtype] = explode('/', $fullType); 79 | 80 | if (!$subtype) { 81 | throw new UnexpectedValueException('Malformed media-range: ' . $mediaRange); 82 | } 83 | 84 | $plusPos = strpos($subtype, '+'); 85 | if ($plusPos !== false) { 86 | $genericSubtype = substr($subtype, $plusPos + 1); 87 | } else { 88 | $genericSubtype = $subtype; 89 | } 90 | 91 | return [trim($type), trim($subtype), $params, $genericSubtype]; 92 | } 93 | 94 | /** 95 | * Parses a media-range via `Mimeparse::parseMediaRange()` and guarantees that 96 | * there is a value for the `q` param, filling it in with a proper default 97 | * if necessary. 98 | * 99 | * @return array{string, string, array, string} 100 | */ 101 | protected static function parseAndNormalizeMediaRange(string $mediaRange): array 102 | { 103 | $parsedMediaRange = self::parseMediaRange($mediaRange); 104 | $params = $parsedMediaRange[2]; 105 | 106 | if ( 107 | !isset($params['q']) 108 | || !is_numeric($params['q']) 109 | || floatval($params['q']) > 1 110 | || floatval($params['q']) < 0 111 | ) { 112 | $parsedMediaRange[2]['q'] = '1'; 113 | } 114 | 115 | return $parsedMediaRange; 116 | } 117 | 118 | /** 119 | * Find the best match for a given mime-type against a list of 120 | * media-ranges that have already been parsed by 121 | * `Mimeparse::parseAndNormalizeMediaRange()` 122 | * 123 | * Returns the fitness and the `q` quality parameter of the best match, or 124 | * an array of `[-1, 0]` if no match was found. Just as for `Mimeparse::quality()`, 125 | * `$parsedRanges` must be an array of parsed media-ranges. 126 | * 127 | * @param list, string}> $parsedRanges 128 | * 129 | * @return array{float, int} 130 | */ 131 | protected static function qualityAndFitnessParsed(string $mimeType, array $parsedRanges): array 132 | { 133 | $bestFitness = -1; 134 | $bestFitQuality = 0; 135 | [$targetType, $targetSubtype, $targetParams] = self::parseAndNormalizeMediaRange($mimeType); 136 | 137 | foreach ($parsedRanges as $item) { 138 | [$type, $subtype, $params] = $item; 139 | 140 | if ( 141 | ($type === $targetType || $type === '*' || $targetType === '*') 142 | && ($subtype === $targetSubtype || $subtype === '*' || $targetSubtype === '*') 143 | ) { 144 | $paramMatches = 0; 145 | foreach ($targetParams as $k => $v) { 146 | if ($k !== 'q' && isset($params[$k]) && $v === $params[$k]) { 147 | $paramMatches++; 148 | } 149 | } 150 | 151 | $fitness = $type === $targetType && $targetType !== '*' ? 100 : 0; 152 | $fitness += $subtype === $targetSubtype && $targetSubtype !== '*' ? 10 : 0; 153 | $fitness += $paramMatches; 154 | 155 | if ($fitness > $bestFitness) { 156 | $bestFitness = $fitness; 157 | $bestFitQuality = $params['q']; 158 | } 159 | } 160 | } 161 | 162 | return [(float) $bestFitQuality, $bestFitness]; 163 | } 164 | 165 | /** 166 | * Find the best match for a given mime-type against a list of 167 | * media-ranges that have already been parsed by 168 | * `Mimeparse::parseAndNormalizeMediaRange()` 169 | * 170 | * Returns the `q` quality parameter of the best match, `0` if no match was 171 | * found. This function behaves the same as `Mimeparse::quality()` except 172 | * that `$parsedRanges` must be an array of parsed media-ranges. 173 | * 174 | * @param list, string}> $parsedRanges 175 | */ 176 | protected static function qualityParsed(string $mimeType, array $parsedRanges): float 177 | { 178 | [$q] = self::qualityAndFitnessParsed($mimeType, $parsedRanges); 179 | 180 | return $q; 181 | } 182 | 183 | /** 184 | * Returns the quality "q" of a mime-type when compared against the 185 | * media-ranges in ranges. For example: 186 | * 187 | * ``` 188 | * Mimeparse::quality("text/html", "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, *\/*;q=0.5") 189 | * => 0.7 190 | * ``` 191 | */ 192 | public static function quality(string $mimeType, string $ranges): float 193 | { 194 | $ranges = explode(',', $ranges); 195 | $parsedRanges = []; 196 | 197 | foreach ($ranges as $r) { 198 | $parsedRanges[] = self::parseAndNormalizeMediaRange($r); 199 | } 200 | 201 | return self::qualityParsed($mimeType, $parsedRanges); 202 | } 203 | 204 | /** 205 | * Takes a list of supported mime-types and finds the best match for all 206 | * the media-ranges listed in header. The value of $header must be a 207 | * string that conforms to the format of the HTTP Accept: header. The 208 | * value of $supported is an array of mime-types. 209 | * 210 | * In case of ties the mime-type with the lowest index in $supported will 211 | * be used. 212 | * 213 | * ``` 214 | * Mimeparse::bestMatch(["application/xbel+xml", "text/xml"], "text/*;q=0.5,*\/*; q=0.1") 215 | * => "text/xml" 216 | * ``` 217 | * 218 | * @param list $supported 219 | */ 220 | public static function bestMatch(array $supported, string $header): ?string 221 | { 222 | $header = explode(',', $header); 223 | $parsedHeader = []; 224 | 225 | foreach ($header as $range) { 226 | $parsedHeader[] = self::parseAndNormalizeMediaRange($range); 227 | } 228 | 229 | $weightedMatches = []; 230 | foreach ($supported as $index => $mimeType) { 231 | [$quality, $fitness] = self::qualityAndFitnessParsed($mimeType, $parsedHeader); 232 | if ($quality) { 233 | // Mime-types closer to the beginning of the array are 234 | // preferred. This preference score is used to break ties. 235 | $preference = 0 - $index; 236 | $weightedMatches[] = [ 237 | [$quality, $fitness, $preference], 238 | $mimeType, 239 | ]; 240 | } 241 | } 242 | 243 | // Note that since fitness and preference are present in 244 | // $weightedMatches they will also be used when sorting (after quality 245 | // level). 246 | array_multisort($weightedMatches); 247 | 248 | /** @var array{array{float, int, int}, string} $firstChoice */ 249 | $firstChoice = array_pop($weightedMatches); 250 | $quality = $firstChoice[0][0] ?? 0; 251 | 252 | return $quality > 0 ? $firstChoice[1] : null; 253 | } 254 | 255 | /** 256 | * Disable access to constructor 257 | * 258 | * @codeCoverageIgnore 259 | */ 260 | private function __construct() 261 | { 262 | } 263 | } 264 | --------------------------------------------------------------------------------