Skip to content
Merged
145 changes: 123 additions & 22 deletions Annotations/AnnotationGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,12 @@ protected function buildAnnotationForMethod(array $rules, string $pluginName, \R
$existing = $reflectionMethod->getDocComment();
// Skip methods which have been marked as internal or auto annotations disabled
if (
$existing !== false && (stripos($existing, 'OA-AUTO:OFF') !== false
|| stripos($existing, '@internal') !== false
|| stripos($existing, '@hide') !== false)
$existing !== false
&& (
stripos($existing, '@internal') !== false
|| stripos($existing, '@hide') !== false
|| stripos($existing, '@deprecated') !== false
)
) {
return [];
}
Expand Down Expand Up @@ -947,17 +950,23 @@ public function convertExampleXmlToObject(string $xml): array
$root = new \SimpleXMLElement($xml);

$toArray = function (\SimpleXMLElement $node) use (&$toArray) {
if (!count($node->children())) {
if (!count($node->children()) && !count($node->attributes())) {
return trim((string)$node);
}
// Group children by tag name; repeated names become arrays

// Handle any attributes
$grouped = [];
foreach ($node->attributes() as $attribute) {
$grouped[OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME][] = [$attribute->getName() => (string) $attribute];
}

// Group children by tag name; repeated names become arrays
foreach ($node->children() as $child) {
$name = $child->getName();
$grouped[$name][] = $toArray($child);
}
return array_map(function ($items) {
return (count($items) === 1) ? $items[0] : $items;
return (count($items) === 1) ? array_pop($items) : $items;
}, $grouped);
};

Expand Down Expand Up @@ -1011,7 +1020,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth

// If the return type is void, use the generic response type
if (empty($successArray['ref']) && !empty($returnType) && strval($returnType) === 'void') {
$successArray['ref'] = '#/components/responses/GenericSuccessNoBody';
$successArray['ref'] = '#/components/responses/GenericSuccess';
}

// If it's a generic type and there's no custom description, use one of the global generic responses
Expand Down Expand Up @@ -1133,8 +1142,13 @@ protected function buildMediaTypePropertiesArray(string $format, string $example
{
$contentType = $format === 'json' ? 'application/json' : ($format === 'xml' ? 'text/xml' : 'application/vnd.ms-excel');

$jsonSchema = $format === 'json' ? $this->buildSchemaAnnotationFromJsonExample(json_decode($exampleValue, true) ?? []) : [];
$xmlSchema = $format === 'xml' ? $this->buildSchemaAnnotationFromXmlExample(json_decode($exampleValue, true) ?? []) : [];
$decodedExampleValue = json_decode($exampleValue, true) ?? [];
$jsonSchema = $format === 'json' ? $this->buildSchemaAnnotationFromJsonExample($decodedExampleValue) : [];
$xmlSchema = $format === 'xml' ? $this->buildSchemaAnnotationFromXmlExample($decodedExampleValue) : [];
// If the XML example contains the temporary property to assist in building XML attributes in the schema, replace with newly encoded array with property removed
if ($format === 'xml' && strpos($exampleValue, OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME) !== false) {
$exampleValue = json_encode($decodedExampleValue);
}

if (in_array($format, ['json', 'xml'])) {
// The annotation expects objects and not arrays, so replace [] with {}
Expand Down Expand Up @@ -1323,38 +1337,68 @@ public function buildPropertyAnnotationFromJsonExample(string $propName, array $
/**
* Take the deserialised structure of an XML node and build the lines of an OA\Schema annotation object for it.
*
* @param array $xmlArrayObject Nested array of properties of the XML node.
* @param array $xmlArrayObject Nested array of properties of the XML node. Passed by reference so that temporary
* properties can be removed before the example is included in the annotations.
* @param string $root Name of the root element. The default is 'result'.
*
* @return array Collection of potentially nested arrays representing an OA\Property annotation object.
*/
public function buildSchemaAnnotationFromXmlExample(array $xmlArrayObject, string $root = 'result'): array
public function buildSchemaAnnotationFromXmlExample(array &$xmlArrayObject, string $root = 'result'): array
{
$lines = [
'type="object",',
sprintf('@OA\Xml(name="%s"),', $root),
];

foreach ($xmlArrayObject as $key => $value) {
foreach ($xmlArrayObject as $key => &$value) {
// If the value is not an array, skip
if (!is_array($value)) {
continue;
}

if (count($value) === 1) {
$keys = array_keys($value);
// Skip if it's not a named property
// Skip if it's not a named property and isn't an array
if (!is_string(reset($keys)) && !is_array(reset($value))) {
continue;
}
}

$lines[] = $this->buildPropertyAnnotationFromXmlExample($key, $value);

// Recursively remove all instances of the temporary XML attributes property
$this->removeTempOaXmlAttributeProperty($value);
}

return ['@OA\Schema' => $lines];
}

/**
* Iterate over a nested array representing an example response object and recursively remove all occurrences of the
* temporary property used to help build the schema for XML attributes.
*
* @param array $decodedExampleValue The reference to the nested array to remove the temporary property from.
*
* @return void
*/
protected function removeTempOaXmlAttributeProperty(array &$decodedExampleValue): void
{
foreach ($decodedExampleValue as $key => &$value) {
if ($key === OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME) {
unset($decodedExampleValue[$key]);
// Add the attributes as actual properties so that they are visible in the example
foreach ($value as $attributeName => $attributeValue) {
$decodedExampleValue[$attributeName] = $attributeValue;
}
continue;
}

if (is_array($value)) {
$this->removeTempOaXmlAttributeProperty($value);
}
}
}

/**
* Take the deserialised structure of an XML node and build the lines of an OA\Property annotation object for it.
*
Expand All @@ -1366,9 +1410,17 @@ public function buildSchemaAnnotationFromXmlExample(array $xmlArrayObject, strin
public function buildPropertyAnnotationFromXmlExample(string $propName, array $values): array
{
$type = 'object';
$originalValues = $values;
if ($propName === 'row') {
$type = 'array';
$values = is_array($values[0] ?? null) ? $values[0] : [];
// Merge the rows together to get as many properties as possible
$mergedValues = [];
foreach ($values as $value) {
if (is_array($value)) {
$mergedValues = array_merge($mergedValues, $value);
}
}
$values = $mergedValues;
}

// Set the common properties
Expand All @@ -1377,6 +1429,7 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
sprintf('type="%s",', $type),
];

$hasAttributes = false;
$childLines = [];
// Recursively check if any of the children are arrays
foreach ($values as $key => $value) {
Expand All @@ -1385,6 +1438,13 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
continue;
}

// Special handling for XML attributes
if ($key === OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME) {
$hasAttributes = true;
$childLines = array_merge($childLines, $this->buildXmlAttributeSchemaLines($value));
continue;
}

// Handle nested arrays
if (!is_string($key)) {
if (!is_array(reset($value))) {
Expand All @@ -1408,8 +1468,8 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
];

// Handle arrays of strings which don't have named properties
$keys = array_keys($values);
if (!is_string(reset($keys)) && count($values) === 1) {
$originalKeys = array_keys($originalValues);
if (!is_string(reset($originalKeys)) && !is_string(reset($values)) && !$hasAttributes) {
$itemProperties = ['type="string"'];
}

Expand All @@ -1419,6 +1479,43 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
return ['@OA\Property' => array_merge($propertyLines, $childLines)];
}

/**
* Build the array of lines for the attribute properties of an XML schema annotation object. It accepts an array of
* arrays representing the attributes of an XML node. It can also handle a single array of key/value pairs.
*
* @param array $attributes Collection of attributes and values. E.g. [['key1' => 'value1'],['key2' => 'value2']] or
* ['key1' => 'value1', 'key2' => 'value2']
*
* @return array The lines defining the property annotation objects for the XML attributes.
* E.g. [['@OA\Property' => ['property="idgoal",', 'type="string",', '@OA\Xml(attribute=true),', 'example="2"']]]
*/
public function buildXmlAttributeSchemaLines(array $attributes): array
{
$attributeSchemaLines = [];
foreach ($attributes as $index => $attribute) {
$keys = is_array($attribute) ? array_keys($attribute) : [];
$key = count($keys) === 1 ? $keys[0] : $index;
$value = trim(is_array($attribute) ? $attribute[$key] ?? '' : $attribute);
// Allow attributes with empty values, but an attribute must always have a name
if (empty($key)) {
continue;
}
// Initialise with the lines that will always be present
$propertyLines = [
sprintf('property="%s",', $key),
'type="string",',
'@OA\Xml(attribute=true),',
];
// Add the example line if there's an actual value
if (!empty($value) || strlen($value) > 0) {
$propertyLines[] = sprintf('example="%s"', $value);
}
$attributeSchemaLines[] = ['@OA\Property' => $propertyLines];
}

return $attributeSchemaLines;
}

/**
* Take a list of lines and remove the trailing comma from the last line.
*
Expand Down Expand Up @@ -1463,6 +1560,9 @@ public function buildLinesForAnnotationObject(string $objectName, array $objectP

// If it's not an object, then it's an array of similarly named objects, like parameters
foreach ($property as $subPropIndex => $subProperty) {
if (!is_string($subPropIndex)) {
continue;
}
$lines = array_merge($lines, $this->buildLinesForAnnotationObject($subPropIndex, $subProperty, $indent + 1));
}
}
Expand Down Expand Up @@ -1549,12 +1649,13 @@ public function wrapStringWithQuotes(string $string, string $type, string $quote
*/
public function shouldIncludeDefault(string $type, string $default = NoDefaultValue::class): bool
{
if ($default === NoDefaultValue::class) {
return false;
}

// Don't use true or false for default if it's not a boolean type
if ($type !== 'boolean' && in_array(strtolower($default), ['false', 'true'])) {
if (
$default === NoDefaultValue::class
|| ($type === 'number' && !is_numeric($default))
|| ($type === 'integer' && !\ctype_digit($default))
|| ($type !== 'string' && $default === '')
|| ($type !== 'boolean' && in_array(strtolower($default), ['false', 'true']))
) {
return false;
}

Expand Down
30 changes: 26 additions & 4 deletions Annotations/GlobalApiComponents.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,29 @@
* additionalProperties=true,
* @OA\Property(property="result", type="string", example="success"),
* @OA\Property(property="message", type="string", example="ok"),
* @OA\Property(property="code", type="integer", example="200")
* @OA\Property(property="code", type="integer", example="200"),
* example={"result":"success","message":"ok"}
* )
*
* @OA\Schema(
* schema="GenericSuccessXml",
* type="object",
* description="Generic Matomo success payload in XML.",
* required={"success"},
* additionalProperties=true,
* @OA\Xml(name="result"),
* @OA\Property(
* property="success",
* type="object",
* @OA\Xml(name="success"),
* @OA\Property(
* property="message",
* type="string",
* @OA\Xml(attribute=true),
* example="ok"
* )
* ),
* example={"success":{"message":"ok"}}
* )
*
* Generic Error object
Expand Down Expand Up @@ -160,9 +182,9 @@
* response="GenericSuccess",
* description="Generic 200 response",
* @OA\JsonContent(ref="#/components/schemas/GenericSuccess"),
* @OA\XmlContent(ref="#/components/schemas/GenericSuccess"),
* @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Result: success"),
* @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="success")
* @OA\XmlContent(ref="#/components/schemas/GenericSuccessXml"),
* @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Success:ok"),
* @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="<!-- Success: ok -->")
* )
*
* @OA\Response(
Expand Down
2 changes: 2 additions & 0 deletions OpenApiDocs.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
class OpenApiDocs extends \Piwik\Plugin
{
public const DEFAULT_SPEC_VERSION = '1.0.0';
public const OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME = 'oaXmlAttributes';
public const GENERATED_ANNOTATIONS_PATH = '/tmp/annotations/';
public const EXAMPLE_RESPONSES_PATH = '/tmp/responses/';
public const GENERATED_SPECS_PATH = '/tmp/specs/';
public const AVAILABLE_PROPERTY_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'];

public function registerEvents()
{
Expand Down
1 change: 1 addition & 0 deletions tests/Resources/ExampleResponses/API.get.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"nb_uniq_visitors":8920,"nb_visits":9831,"nb_users":5,"nb_actions":29397,"max_actions":76,"bounce_count":5760,"sum_visit_length":2556198,"nb_visits_new":5,"nb_actions_new":8,"nb_uniq_visitors_new":5,"nb_users_new":0,"max_actions_new":4,"bounce_rate_new":"80%","nb_actions_per_visit_new":1.6,"avg_time_on_site_new":450,"nb_visits_returning":9829,"nb_actions_returning":29392,"nb_uniq_visitors_returning":8917,"nb_users_returning":5,"max_actions_returning":76,"bounce_rate_returning":"59%","nb_actions_per_visit_returning":3,"avg_time_on_site_returning":260,"Referrers_visitorsFromSearchEngines":6969,"Referrers_visitorsFromSocialNetworks":1111,"Referrers_visitorsFromAIAssistants":0,"Referrers_visitorsFromDirectEntry":957,"Referrers_visitorsFromWebsites":795,"Referrers_visitorsFromCampaigns":0,"Referrers_distinctSearchEngines":20,"Referrers_distinctSocialNetworks":5,"Referrers_distinctAIAssistants":0,"Referrers_distinctKeywords":96,"Referrers_distinctWebsites":4,"Referrers_distinctWebsitesUrls":277,"Referrers_distinctCampaigns":0,"PagePerformance_network_time":0,"PagePerformance_network_hits":0,"PagePerformance_servery_time":0,"PagePerformance_server_hits":0,"PagePerformance_transfer_time":0,"PagePerformance_transfer_hits":0,"PagePerformance_domprocessing_time":0,"PagePerformance_domprocessing_hits":0,"PagePerformance_domcompletion_time":0,"PagePerformance_domcompletion_hits":0,"PagePerformance_onload_time":0,"PagePerformance_onload_hits":0,"PagePerformance_pageload_time":0,"PagePerformance_pageload_hits":0,"avg_time_network":0,"avg_time_server":0,"avg_time_transfer":0,"avg_time_dom_processing":0,"avg_time_dom_completion":0,"avg_time_on_load":0,"avg_page_load_time":0,"nb_plays":184,"nb_unique_visitors_plays":147,"nb_impressions":1313,"nb_unique_visitors_impressions":1022,"nb_finishes":38,"sum_total_time_watched":48918,"sum_total_audio_plays":29,"sum_total_audio_impressions":66,"sum_total_video_plays":155,"sum_total_video_impressions":1247,"nb_conversions":962,"nb_visits_converted":915,"revenue":84869.05,"conversion_rate":"9.31%","nb_conversions_new_visit":0,"nb_visits_converted_new_visit":0,"revenue_new_visit":0,"conversion_rate_new_visit":"0%","nb_conversions_returning_visit":962,"nb_visits_converted_returning_visit":915,"revenue_returning_visit":84869.05,"conversion_rate_returning_visit":"9.31%","nb_form_views":11746,"nb_form_viewers":7731,"nb_form_starts":513,"nb_form_starters":432,"nb_form_submissions":578,"nb_form_submitters":557,"nb_form_resubmitters":15,"nb_form_conversions":136,"nb_crash_occurrences":950,"nb_visits_with_crash":812,"nb_ignored_crashes":0,"nb_uniq_crashes":13,"nb_new_crashes":0,"nb_disappeared_crashes":0,"nb_reappeared_crashes":0,"nb_pageviews":25071,"nb_uniq_pageviews":19429,"nb_downloads":22,"nb_uniq_downloads":20,"nb_outlinks":1378,"nb_uniq_outlinks":1296,"nb_searches":216,"nb_keywords":73,"hits":29816,"Referrers_visitorsFromDirectEntry_percent":"10%","Referrers_visitorsFromSearchEngines_percent":"71%","Referrers_visitorsFromAIAssistants_percent":"0%","Referrers_visitorsFromCampaigns_percent":"0%","Referrers_visitorsFromSocialNetworks_percent":"11%","Referrers_visitorsFromWebsites_percent":"8%","visits_crash_rate":0.08,"bounce_rate":"59%","nb_actions_per_visit":3,"avg_time_on_site":260,"form_starters_rate":"5.6%","form_submitter_rate":"128.9%","form_conversion_rate":"31.5%","form_resubmitters_rate":"2.7%","avg_form_time_hesitation":17.189,"avg_form_time_spent":109.457,"avg_form_time_to_first_submission":9.059,"avg_form_time_to_conversion":83.703,"play_rate":0.14,"finish_rate":0.21,"impression_rate":0.11}
Loading