Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 300 additions & 0 deletions ci/phpunit/fixtures/openapi/config.spec.json

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions ci/phpunit/inc/apiv2/openapi/FullSpecTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Hashtopolis\inc\apiv2\openapi;

use DI\Container;
use Hashtopolis\inc\apiv2\common\AbstractModelAPI;
use Hashtopolis\inc\apiv2\common\ApiRegistry;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -255,6 +257,46 @@ public function testKnownShapeSpotChecks(): void {
);
}

/**
* Every option offered by getAggregateFieldsets() has to be declared in
* getAggregateFeatures(), or a client asking for the aggregate receives an
* attribute the document does not describe. Aggregates are only produced on
* request, so they belong in "properties" but never in "required".
*/
public function testAggregateFieldsetsAreDocumented(): void {
$container = new Container();
$checked = 0;

foreach (ApiRegistry::MODEL_API_CLASSES as $apiClass) {
$fieldsets = (new $apiClass($container))->getAggregateFieldsets();
if (count($fieldsets) === 0) {
continue;
}
$declared = $apiClass::getAggregateFeatures();
$nameParts = explode('\\', $apiClass);
$name = substr(end($nameParts), 0, -3); // Remove "API" suffix

$this->assertArrayHasKey($name . 'Response', self::$sanitized['components']['schemas']);
$attributes = self::$sanitized['components']['schemas'][$name . 'Response']['properties']['data']['properties']['attributes'];
/* An attributes override offers a choice of shapes, any of which can carry an aggregate */
$branches = $attributes['oneOf'] ?? [$attributes];

foreach ($fieldsets as $fieldset) {
foreach (array_keys($fieldset) as $field) {
$this->assertArrayHasKey($field, $declared, "$apiClass offers aggregate '$field' but does not declare it in getAggregateFeatures()");
$this->assertSame($field, $declared[$field]['alias'], "Aggregate '$field' of $apiClass is declared under a different alias");
foreach ($branches as $branch) {
$this->assertArrayHasKey($field, $branch['properties'], "Aggregate '$field' missing from {$name}Response");
$this->assertNotContains($field, $branch['required'] ?? [], "Aggregate '$field' is only returned on request, so it must not be required in {$name}Response");
}
$checked++;
}
}
}

$this->assertGreaterThanOrEqual(25, $checked, 'Expected the registered APIs to offer aggregates');
}

public function testNoSchemaIsNamedAfterAMissingRelation(): void {
foreach (['raw' => self::$raw, 'sanitized' => self::$sanitized] as $variant => $spec) {
foreach (array_keys($spec['components']['schemas']) as $name) {
Expand Down
708 changes: 708 additions & 0 deletions openapi.json

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions src/inc/apiv2/common/AbstractBaseAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ public function getFormFields(): array {
* Replace the OpenAPI "attributes" schema for the GET response. Return null
* (default) to let the generator derive the schema from features. Return a
* full JSON-schema object (e.g. ["oneOf" => [...]]) to substitute it.
*
* This replaces the feature-derived attributes only. The properties declared
* by getAggregateFeatures() are merged into the result either way, so an
* override cannot silently drop them.
*/
public function getOpenAPIAttributesSchemaOverride(): ?array {
return null;
Expand Down Expand Up @@ -230,6 +234,50 @@ public function aggregateData(AbstractModel $object, array &$includedData = [],
public function getAggregateFieldsets(): array {
return [];
}

/**
* Declare computed properties returned by aggregateData() for OpenAPI schema generation.
* Override in subclasses that implement aggregateData().
*
* Every field offered by getAggregateFieldsets() should be declared here, so
* that a client asking for `aggregate[<resource>]=<field>` finds the field in
* the response schema. Aggregates are only produced on request, so they end
* up in the "properties" of the attributes schema but never in its
* "required" list.
*/
public static function getAggregateFeatures(): array {
return [];
}

/**
* Build the feature declaration of a single computed property returned by
* aggregateData(). Aggregates are derived on the fly rather than stored, so
* they are always read-only, never a primary key and never part of the dba
* mapping.
*
* A callback returning null contributes no key at all (see aggregateData()),
* so an optional aggregate is an absent property rather than a null one and
* does not need 'null' => true.
*
* @param string $type feature type, as used by the dba model features
* @param array<string, mixed> $overrides entries replacing the defaults, e.g. 'choices'
* @return array<string, mixed>
*/
final protected static function aggregateFeature(string $type, string $alias, array $overrides = []): array {
return array_merge([
'type' => $type,
'alias' => $alias,
'pk' => false,
'private' => false,
'choices' => 'unset',
'null' => false,
'protected' => false,
'read_only' => true,
'subtype' => 'unset',
'public' => false,
'dba_mapping' => false,
], $overrides);
}

/**
* Take all the dba features and converts them to a list.
Expand Down
8 changes: 7 additions & 1 deletion src/inc/apiv2/model/AgentAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ public function getAggregateFieldsets(): array {
]
];
}


public static function getAggregateFeatures(): array {
return [
'crackingTime' => self::aggregateFeature('int', 'crackingTime'),
];
}

/**
* @param Agent $object
* @return int
Expand Down
13 changes: 12 additions & 1 deletion src/inc/apiv2/model/AgentAssignmentAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,18 @@ public function getAggregateFieldsets(): array {
]
];
}


public static function getAggregateFeatures(): array {
return [
'crackingTime' => self::aggregateFeature('int', 'crackingTime'),
'cracked' => self::aggregateFeature('int', 'cracked'),
'currentSpeed' => self::aggregateFeature('int', 'currentSpeed'),
/* No chunk in progress yields no key rather than a null one */
'currentChunkId' => self::aggregateFeature('int', 'currentChunkId'),
'searched' => self::aggregateFeature('int', 'searched'),
];
}

/**
* @param Assignment $object
* @return int
Expand Down
6 changes: 6 additions & 0 deletions src/inc/apiv2/model/ApiTokenAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ protected function getFilterACL(): array {
];
}

public static function getAggregateFeatures(): array {
return [
'token' => self::aggregateFeature('str', 'token'),
];
}

/**
* @throws HttpError
* @throws ResourceNotFoundError
Expand Down
27 changes: 27 additions & 0 deletions src/inc/apiv2/model/ConfigAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,33 @@ public function getAggregateFieldsets(): array {
];
}

/**
* The bounds an item accepts depend on its config type, so which members are
* present varies per item (see ConfigUtils::getConfigValueBounds). The object
* is never empty: an item of unknown type is treated as a string input and
* reports at least its maximum length.
*/
public static function getAggregateFeatures(): array {
return [
'valueBoundaries' => self::aggregateFeature('dict', 'valueBoundaries', [
'openapi_schema' => [
'type' => 'object',
'minProperties' => 1,
'properties' => [
'min' => ['type' => 'integer', 'description' => 'Smallest accepted value of a numeric item'],
'max' => ['type' => 'integer', 'description' => 'Largest accepted value of a numeric item'],
'maxLength' => ['type' => 'integer', 'description' => 'Longest accepted value of a textual item'],
'binaryValues' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'The two values a tickbox item accepts',
],
],
],
]),
];
}

protected function getAggregateValueBoundaries(AbstractModel $object): ?array {
if (!($object instanceof Config) || !is_string($object->getItem()) || $object->getItem() === '') {
return null;
Expand Down
8 changes: 7 additions & 1 deletion src/inc/apiv2/model/PreTaskAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ public function getAggregateFieldsets(): array {
]
];
}


public static function getAggregateFeatures(): array {
return [
'auxiliaryKeyspace' => self::aggregateFeature('int', 'auxiliaryKeyspace'),
];
}

/**
* @param Pretask $object
* @return int
Expand Down
8 changes: 7 additions & 1 deletion src/inc/apiv2/model/SupertaskAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,13 @@ public function getAggregateFieldsets(): array {
]
];
}


public static function getAggregateFeatures(): array {
return [
'amountPretasks' => self::aggregateFeature('int', 'amountPretasks'),
];
}

/**
* @param Supertask $object
* @throws Exception
Expand Down
18 changes: 17 additions & 1 deletion src/inc/apiv2/model/TaskAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Hashtopolis\dba\models\User;
use Hashtopolis\inc\apiv2\common\AbstractModelAPI;
use Hashtopolis\inc\apiv2\error\HttpError;
use Hashtopolis\inc\defines\DTaskStatus;
use Hashtopolis\inc\utils\TaskUtils;
use Hashtopolis\inc\Util;

Expand Down Expand Up @@ -163,7 +164,22 @@ public function getAggregateFieldsets(): array {
]
];
}


public static function getAggregateFeatures(): array {
return [
'totalAssignedAgents' => self::aggregateFeature('int', 'totalAssignedAgents'),
'dispatched' => self::aggregateFeature('str', 'dispatched'),
'searched' => self::aggregateFeature('str', 'searched'),
'status' => self::aggregateFeature('int', 'status', ['choices' => DTaskStatus::choices()]),
'totalNumberOfChunks' => self::aggregateFeature('int', 'totalNumberOfChunks'),
'currentSpeed' => self::aggregateFeature('int', 'currentSpeed'),
'estimatedTime' => self::aggregateFeature('int', 'estimatedTime'),
'cprogress' => self::aggregateFeature('int', 'cprogress'),
'timeSpent' => self::aggregateFeature('int', 'timeSpent'),
'cracked' => self::aggregateFeature('int', 'cracked'),
];
}

/**
* @param Task $object
* @throws Exception
Expand Down
19 changes: 18 additions & 1 deletion src/inc/apiv2/model/TaskWrapperDisplayAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,24 @@ public function getAggregateFieldsets(): array {
]
];
}


/**
* Everything but the agent count is reported for normal tasks only, and a
* supertask wrapper simply omits those keys (see aggregateData()).
*/
public static function getAggregateFeatures(): array {
return [
'totalAssignedAgents' => self::aggregateFeature('int', 'totalAssignedAgents'),
'dispatched' => self::aggregateFeature('str', 'dispatched'),
'searched' => self::aggregateFeature('str', 'searched'),
'status' => self::aggregateFeature('int', 'status', ['choices' => DTaskStatus::choices()]),
'currentSpeed' => self::aggregateFeature('int', 'currentSpeed'),
'estimatedTime' => self::aggregateFeature('int', 'estimatedTime'),
'cprogress' => self::aggregateFeature('int', 'cprogress'),
'timeSpent' => self::aggregateFeature('int', 'timeSpent'),
];
}

/**
* @param TaskWrapperDisplay $object
* @throws Exception
Expand Down
9 changes: 9 additions & 0 deletions src/inc/apiv2/openapi/FeatureTypeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ public function makeProperties($features, $skipPK = false): array {
if ($skipPK && $feature['pk']) {
continue;
}
/**
* A feature can carry its own schema for values the feature types cannot
* describe, such as an object whose members differ in type. It is taken
* verbatim, so the type lookup does not apply to it.
*/
if (!empty($feature['openapi_schema'])) {
$propertyVal[$feature['alias']] = $feature['openapi_schema'];
continue;
}
$ret = $this->typeLookup($feature);
$isNullable = $feature['null'] ?? false;
if ($ret["type_enum"] !== null && $ret["type_enum_labels"] !== null) {
Expand Down
36 changes: 34 additions & 2 deletions src/inc/apiv2/openapi/ModelApiPathBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,20 @@ public function addRoute(RouteTarget $target, AbstractModelAPI $api, ContainerIn
if (!array_key_exists($name, $components)) {
$responseFeatures = array_filter($class->getFeaturesWithoutFormfields(), fn($f) => !$f['private']);
$responseAttributeProperties = $this->typeMapper->makeProperties($responseFeatures, true);
$aggregateFeatures = $class->getAggregateFeatures();
$aggregateAttributeProperties = $this->typeMapper->makeProperties($aggregateFeatures, true);
$allResponseProperties = array_merge($responseAttributeProperties, $aggregateAttributeProperties);
$attributesOverride = $class->getOpenAPIAttributesSchemaOverride();
if ($attributesOverride !== null) {
$attributesSchema = $attributesOverride;
$attributesSchema = $this->addAggregateProperties($attributesOverride, $aggregateAttributeProperties);
} else {
$attributesSchema = [
"type" => "object",
"required" => array_values(array_map(
fn($f) => $f['alias'],
array_filter($responseFeatures, fn($f) => !$f['pk'])
)),
"properties" => $responseAttributeProperties
"properties" => $allResponseProperties
];
}
/**
Expand Down Expand Up @@ -532,6 +535,35 @@ public function addRoute(RouteTarget $target, AbstractModelAPI $api, ContainerIn
$paths[$path][$method]["parameters"] = $parameters;
}

/**
* Add the aggregate properties to an attributes schema supplied by
* getOpenAPIAttributesSchemaOverride(). The override replaces the
* feature-derived attributes, but aggregateData() still appends its fields to
* whatever the override describes, so they have to be part of it.
*
* An override that offers a choice of shapes carries the aggregates in every
* branch, since any of them can be returned with an aggregate requested.
*
* @param array<string, mixed> $schema
* @param array<string, mixed> $aggregateProperties
* @return array<string, mixed>
*/
private function addAggregateProperties(array $schema, array $aggregateProperties): array {
if (count($aggregateProperties) === 0) {
return $schema;
}
if (array_key_exists("oneOf", $schema)) {
$schema["oneOf"] = array_map(
fn(array $branch) => $this->addAggregateProperties($branch, $aggregateProperties),
$schema["oneOf"]
);
return $schema;
}
/* Aggregates are only produced on request, so they are never required */
$schema["properties"] = array_merge($schema["properties"] ?? [], $aggregateProperties);
return $schema;
}

/**
* The "filter" query parameter, a deep object whose keys are attribute names
* optionally suffixed with a comparison operator.
Expand Down