MiniApiContract.php 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. <?php
  2. /** Project-local checks for the OpenAPI schema subset used by the mini API fixtures. */
  3. final class MiniApiContract
  4. {
  5. public readonly array $document;
  6. public function __construct()
  7. {
  8. $this->document = json_decode(file_get_contents(dirname(__DIR__, 2) . '/docs/openapi/water-mini-v1.json'), true, 512, JSON_THROW_ON_ERROR);
  9. }
  10. public function data(string $name, mixed $data): bool
  11. {
  12. // Match wire JSON object/array distinctions rather than PHP associative arrays.
  13. $this->validate($this->document['components']['schemas'][$name], json_decode(json_encode($data, JSON_THROW_ON_ERROR)), $name);
  14. return true;
  15. }
  16. public function resolve(string $reference): array
  17. {
  18. if (!str_starts_with($reference, '#/')) {
  19. throw new RuntimeException('Only local schema references are expected: ' . $reference);
  20. }
  21. $node = $this->document;
  22. foreach (explode('/', substr($reference, 2)) as $part) {
  23. $node = $node[str_replace(['~1', '~0'], ['/', '~'], $part)] ?? null;
  24. if (!is_array($node)) {
  25. throw new RuntimeException('Unresolved reference: ' . $reference);
  26. }
  27. }
  28. return $node;
  29. }
  30. public function validate(array $schema, mixed $value, string $path = 'data'): void
  31. {
  32. if (isset($schema['$ref'])) {
  33. $this->validate($this->resolve($schema['$ref']), $value, $path);
  34. return;
  35. }
  36. if ($value === null && ($schema['nullable'] ?? false) && isset($schema['type'])) {
  37. return;
  38. }
  39. foreach ($schema['allOf'] ?? [] as $part) {
  40. $this->validate($part, $value, $path);
  41. }
  42. foreach (['oneOf', 'anyOf'] as $union) {
  43. if (!isset($schema[$union])) {
  44. continue;
  45. }
  46. $matches = 0;
  47. foreach ($schema[$union] as $part) {
  48. try {
  49. $this->validate($part, $value, $path);
  50. $matches++;
  51. } catch (RuntimeException) {
  52. }
  53. }
  54. if ($matches === 0 || ($union === 'oneOf' && $matches !== 1)) {
  55. throw new RuntimeException($path . ': invalid ' . $union);
  56. }
  57. }
  58. $type = $schema['type'] ?? null;
  59. $valid = match ($type) {
  60. 'object' => $value instanceof stdClass,
  61. 'array' => is_array($value),
  62. 'integer' => is_int($value),
  63. 'number' => is_int($value) || is_float($value),
  64. 'string' => is_string($value),
  65. 'boolean' => is_bool($value),
  66. null => true,
  67. default => false,
  68. };
  69. if (!$valid || (isset($schema['enum']) && !in_array($value, $schema['enum'], true))) {
  70. throw new RuntimeException($path . ': expected ' . $type);
  71. }
  72. if ($type === 'object') {
  73. foreach ($schema['required'] ?? [] as $key) {
  74. if (!property_exists($value, $key)) {
  75. throw new RuntimeException($path . ': missing ' . $key);
  76. }
  77. }
  78. foreach (get_object_vars($value) as $key => $item) {
  79. $child = $schema['properties'][$key] ?? $schema['additionalProperties'] ?? true;
  80. if ($child === false) {
  81. throw new RuntimeException($path . ': unexpected ' . $key);
  82. }
  83. if (is_array($child)) {
  84. $this->validate($child, $item, $path . '.' . $key);
  85. }
  86. }
  87. }
  88. if ($type === 'array') {
  89. if (count($value) < ($schema['minItems'] ?? 0) || count($value) > ($schema['maxItems'] ?? PHP_INT_MAX)) {
  90. throw new RuntimeException($path . ': array length');
  91. }
  92. if (($schema['uniqueItems'] ?? false) && count(array_unique(array_map('serialize', $value))) !== count($value)) {
  93. throw new RuntimeException($path . ': duplicate item');
  94. }
  95. foreach ($value as $index => $item) {
  96. $this->validate($schema['items'], $item, $path . '[' . $index . ']');
  97. }
  98. }
  99. if ($type === 'string') {
  100. if (mb_strlen($value) < ($schema['minLength'] ?? 0) || mb_strlen($value) > ($schema['maxLength'] ?? PHP_INT_MAX)
  101. || (isset($schema['pattern']) && !preg_match('~' . str_replace('~', '\\~', $schema['pattern']) . '~uD', $value))) {
  102. throw new RuntimeException($path . ': string constraint');
  103. }
  104. }
  105. if (($type === 'integer' || $type === 'number') && ($value < ($schema['minimum'] ?? -INF) || $value > ($schema['maximum'] ?? INF))) {
  106. throw new RuntimeException($path . ': numeric range');
  107. }
  108. }
  109. }