Skip to content
Merged
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
25 changes: 15 additions & 10 deletions generate-spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
}
}
foreach (array_keys($definitions) as $name) {
$schemas[Helpers::cleanSchemaName($name)] = OpenApiType::resolve('Response definitions: ' . $name, $definitions, $definitions[$name])->toArray();
$schemas[Helpers::cleanSchemaName($name)] = OpenApiType::resolve('Response definitions: ' . $name, $definitions, $definitions[$name])->toArray($schemas);
}
} else {
Logger::debug('Response definitions', 'No response definitions were loaded');
Expand Down Expand Up @@ -236,7 +236,7 @@
continue;
}

$schema = $type->toArray();
$schema = $type->toArray($schemas);

if ($implementsPublicCapability) {
$publicCapabilities = $publicCapabilities == null ? $schema : Helpers::mergeSchemas([$publicCapabilities, $schema]);
Expand Down Expand Up @@ -656,7 +656,7 @@
if (count($matchingParameters) === 1) {
$parameter = $matchingParameters[array_keys($matchingParameters)[0]];

$schema = $parameter->type->toArray(true);
$schema = $parameter->type->toArray($schemas, true);
$description = $parameter->type->description;
} else {
$schema = [
Expand Down Expand Up @@ -754,20 +754,20 @@
$contentTypeResponses = array_values(array_filter($statusCodeResponses, fn (ControllerMethodResponse $response): bool => $response->contentType == $contentType));

$hasEmpty = array_filter($contentTypeResponses, fn (ControllerMethodResponse $response): bool => $response->type == null) !== [];
$uniqueResponses = array_values(array_intersect_key($contentTypeResponses, array_unique(array_map(fn (ControllerMethodResponse $response): array|\stdClass => $response->type->toArray(), array_filter($contentTypeResponses, fn (ControllerMethodResponse $response): bool => $response->type != null)), SORT_REGULAR)));
$uniqueResponses = array_values(array_intersect_key($contentTypeResponses, array_unique(array_map(fn (ControllerMethodResponse $response): array|\stdClass => $response->type->toArray($schemas), array_filter($contentTypeResponses, fn (ControllerMethodResponse $response): bool => $response->type != null)), SORT_REGULAR)));
if (count($uniqueResponses) === 1) {
if ($hasEmpty) {
$mergedContentTypeResponses[$contentType] = [];
} else {
$schema = Helpers::cleanEmptyResponseArray($contentTypeResponses[0]->type->toArray());
$schema = Helpers::cleanEmptyResponseArray($contentTypeResponses[0]->type->toArray($schemas));
$mergedContentTypeResponses[$contentType] = ['schema' => Helpers::wrapOCSResponse($route, $contentTypeResponses[0], $schema)];
}
} else {
$mergedContentTypeResponses[$contentType] = [
'schema' => [
// At least one should match, but it's possible that multiple match, so oneOf can't be used.
'anyOf' => array_map(function (ControllerMethodResponse $response) use ($route): stdClass|array {
$schema = Helpers::cleanEmptyResponseArray($response->type->toArray());
'anyOf' => array_map(function (ControllerMethodResponse $response) use ($route, $schemas): stdClass|array {
$schema = Helpers::cleanEmptyResponseArray($response->type->toArray($schemas));
return Helpers::wrapOCSResponse($route, $response, $schema);
}, $uniqueResponses),
],
Expand All @@ -783,7 +783,7 @@
array_keys($headers),
array_map(
fn (OpenApiType $type): array => [
'schema' => $type->toArray(),
'schema' => $type->toArray($schemas),
],
array_values($headers),
),
Expand Down Expand Up @@ -844,7 +844,7 @@
}
$schema['properties'] = [];
foreach ($bodyParameters as $bodyParameter) {
$schema['properties'][$bodyParameter->name] = $bodyParameter->type->toArray();
$schema['properties'][$bodyParameter->name] = $bodyParameter->type->toArray($schemas);
}

$operation['requestBody'] = [
Expand Down Expand Up @@ -872,7 +872,7 @@
if ($queryParameter->type->deprecated) {
$parameter['deprecated'] = true;
}
$parameter['schema'] = $queryParameter->type->toArray(true);
$parameter['schema'] = $queryParameter->type->toArray($schemas, true);

$parameters[] = $parameter;
}
Expand Down Expand Up @@ -1049,6 +1049,11 @@
$usedRefs[] = Helpers::collectUsedRefs($responseData['content']);
}
}
foreach (($routeData['parameters'] ?? []) as $parameterData) {
if (isset($parameterData['schema'])) {
$usedRefs[] = Helpers::collectUsedRefs($parameterData['schema']);
}
}
if (isset($routeData['requestBody']['content']) && $routeData['requestBody']['content'] !== []) {
$usedRefs[] = Helpers::collectUsedRefs($routeData['requestBody']['content']);
}
Expand Down
38 changes: 29 additions & 9 deletions src/OpenApiType.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,35 @@ public function __construct(
) {
}

public function toArray(bool $isParameter = false): array|stdClass {
if ($isParameter && ($this->type === 'object' || $this->ref !== null || $this->anyOf !== null || $this->allOf !== null)) {
/**
* @param array<string, array<string, mixed>> $schemas
*/
private function isParameterSerializable(array $schemas, ?string $type, ?bool $nullable, ?string $ref, ?array $anyOf, ?array $allOf): bool {
if ($ref !== null) {
$prefix = '#/components/schemas/';
if (str_starts_with($ref, $prefix) && ($schema = $schemas[substr($ref, strlen($prefix))] ?? null) !== null) {
return $this->isParameterSerializable($schemas, $schema['type'] ?? null, $schema['nullable'] ?? null, $schema['ref'] ?? null, $schema['anyOf'] ?? null, $schema['allOf'] ?? null);
}

return false;
}

// https://github.com/OAI/OpenAPI-Specification/issues/1368#issuecomment-354037150
return $type !== 'object' && $anyOf === null && ($allOf === null || (($nullable ?? false) && count($allOf) === 1));
}

/**
* @param array<string, array<string, mixed>> $schemas
*/
public function toArray(array $schemas, bool $isParameter = false): array|stdClass {
if ($isParameter && !$this->isParameterSerializable($schemas, $this->type, $this->nullable, $this->ref, $this->anyOf, $this->allOf)) {
Logger::warning($this->context, 'Complex types can not be part of query or URL parameters. Falling back to string due to undefined serialization!');
return (new OpenApiType(
context: $this->context,
type: 'string',
nullable: $this->nullable,
description: $this->description,
))->toArray($isParameter);
))->toArray($schemas, $isParameter);
}

$values = [];
Expand Down Expand Up @@ -101,7 +121,7 @@ public function toArray(bool $isParameter = false): array|stdClass {
$values['description'] = Helpers::cleanDocComment($this->description);
}
if ($this->items instanceof \OpenAPIExtractor\OpenApiType) {
$values['items'] = $this->items->toArray();
$values['items'] = $this->items->toArray($schemas);
}
if ($this->minLength !== null) {
$values['minLength'] = $this->minLength;
Expand All @@ -126,24 +146,24 @@ public function toArray(bool $isParameter = false): array|stdClass {
}
if ($this->properties !== null && $this->properties !== []) {
$values['properties'] = array_combine(array_keys($this->properties),
array_map(static fn (OpenApiType $property): array|\stdClass => $property->toArray(), array_values($this->properties)),
array_map(static fn (OpenApiType $property): array|\stdClass => $property->toArray($schemas), array_values($this->properties)),
);
}
if ($this->additionalProperties !== null) {
if ($this->additionalProperties instanceof OpenApiType) {
$values['additionalProperties'] = $this->additionalProperties->toArray();
$values['additionalProperties'] = $this->additionalProperties->toArray($schemas);
} else {
$values['additionalProperties'] = $this->additionalProperties;
}
}
if ($this->oneOf !== null) {
$values['oneOf'] = array_map(fn (OpenApiType $type): array|\stdClass => $type->toArray(), $this->oneOf);
$values['oneOf'] = array_map(fn (OpenApiType $type): array|\stdClass => $type->toArray($schemas), $this->oneOf);
}
if ($this->anyOf !== null) {
$values['anyOf'] = array_map(fn (OpenApiType $type): array|\stdClass => $type->toArray(), $this->anyOf);
$values['anyOf'] = array_map(fn (OpenApiType $type): array|\stdClass => $type->toArray($schemas), $this->anyOf);
}
if ($this->allOf !== null) {
$values['allOf'] = array_map(fn (OpenApiType $type): array|\stdClass => $type->toArray(), $this->allOf);
$values['allOf'] = array_map(fn (OpenApiType $type): array|\stdClass => $type->toArray($schemas), $this->allOf);
}

return $values !== [] ? $values : new stdClass();
Expand Down
4 changes: 4 additions & 0 deletions tests/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
['name' => 'Settings#intBackedEnumParameter', 'url' => '/api/{apiVersion}/enums/int-backed', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#sortDirectionParameter', 'url' => '/api/{apiVersion}/enums/sort-direction', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#injectedServiceParameter', 'url' => '/api/{apiVersion}/injected-service', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#intersectionTypeEnumParameter', 'url' => '/api/{apiVersion}/intersection-type-enum', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#nullableIntersectionTypeEnumParameter', 'url' => '/api/{apiVersion}/nullable-intersection-type-enum', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#intersectionTypeAliasEnumParameter', 'url' => '/api/{apiVersion}/intersection-type-alias-enum', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#nullableIntersectionTypeAliasEnumParameter', 'url' => '/api/{apiVersion}/nullable-intersection-type-alias-enum', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'V1\SubDir#subDirRoute', 'url' => '/sub-dir', 'verb' => 'GET'],
],
];
49 changes: 49 additions & 0 deletions tests/lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* @psalm-import-type NotificationsPushDevice from ResponseDefinitions
* @psalm-import-type NotificationsNotification from ResponseDefinitions
* @psalm-import-type NotificationsCollection from ResponseDefinitions
* @psalm-import-type NotificationsEnum from ResponseDefinitions
*/
class SettingsController extends OCSController {
/**
Expand Down Expand Up @@ -901,4 +902,52 @@ public function sortDirectionParameter(\SortDirection $direction): DataResponse
public function injectedServiceParameter(IUser $user, string $path): DataResponse {
return new DataResponse();
}

/**
* A route with a intersection type as enum parameter
*
* @param 'A'|'B' $enum The enum
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
*
* 200: OK
*/
public function intersectionTypeEnumParameter(string $enum): DataResponse {
return new DataResponse();
}

/**
* A route with a nullable intersection type as enum parameter
*
* @param null|'A'|'B' $enum The enum
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
*
* 200: OK
*/
public function nullableIntersectionTypeEnumParameter(?string $enum): DataResponse {
return new DataResponse();
}

/**
* A route with a intersection type alias as enum parameter
*
* @param NotificationsEnum $enum The enum
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
*
* 200: OK
*/
public function intersectionTypeAliasEnumParameter(string $enum): DataResponse {
return new DataResponse();
}

/**
* A route with a nullable intersection type alias as enum parameter
*
* @param ?NotificationsEnum $enum The enum
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
*
* 200: OK
*/
public function nullableIntersectionTypeAliasEnumParameter(?string $enum): DataResponse {
return new DataResponse();
}
}
2 changes: 2 additions & 0 deletions tests/lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
* @psalm-type NotificationsSchemaOnlyInCapabilities = array{
* key: string,
* }
*
* @psalm-type NotificationsEnum = 'A'|'B'
*/
class ResponseDefinitions {
}
Loading