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
11 changes: 10 additions & 1 deletion .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}'

- name: Install JavaScript dependencies
run: npm install --no-package-lock
run: npm ci

- name: Install Composer dependencies
uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0
Expand All @@ -73,3 +73,12 @@ jobs:
openapi-administration.json
openapi-full.json
src/types/openapi/*.ts

- name: Check generated OpenAPI files
run: |
if [[ -n "$(git status --porcelain -- openapi.json openapi-administration.json openapi-full.json src/types/openapi)" ]]; then
echo 'Generated OpenAPI files are out of date. Run "composer run openapi" and commit the generated files.'
git status --short -- openapi.json openapi-administration.json openapi-full.json src/types/openapi
git --no-pager diff -- openapi.json openapi-administration.json openapi-full.json src/types/openapi
exit 1
fi
3 changes: 3 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ path = [
"package-lock.json",
"phpstan.neon",
"psalm.xml",
"src/types/openapi/openapi-administration.ts",
"src/types/openapi/openapi-full.ts",
"src/types/openapi/openapi.ts",
"tests/integration/composer.json",
"tests/integration/composer.lock",
"tests/integration/features/api/schema_aggregation.feature",
Expand Down
7 changes: 7 additions & 0 deletions docs/protocol-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ Recommended endpoint shape:
POST /api/v1/reports
```

The reference Nextcloud implementation exposes two equivalent ingestion routes:

- a plain HTTP/JSON route for protocol clients;
- an OCS route for Nextcloud-native tooling and API discovery.

Both accept the same Protocol v1 request fields. The plain route returns the Protocol v1 response body directly. The OCS route wraps the same application-level response in the standard Nextcloud OCS envelope. OCS is an implementation detail of the Nextcloud reference server and is not required by Protocol v1.

The server SHOULD support idempotent resubmission of the same logical report.

A successful submission, including an idempotent retry, returns the same application-level response:
Expand Down
83 changes: 83 additions & 0 deletions lib/Controller/OcsReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\UsageStatisticsServer\Controller;

use OCA\UsageStatisticsServer\Service\ConflictingReport;
use OCA\UsageStatisticsServer\Service\InvalidReport;
use OCA\UsageStatisticsServer\Service\ReportSubmissionService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

#[OpenAPI(tags: ['reports'])]
final class OcsReportController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly ReportSubmissionService $submission,
) {
parent::__construct($appName, $request);
}

/**
* Submit a usage statistics report through the Nextcloud OCS API
*
* This endpoint has the same Protocol v1 request semantics as the plain JSON endpoint,
* but wraps the response using the standard Nextcloud OCS envelope.
*
* @param int $protocolVersion Usage statistics protocol version
* @param string $application Stable application identifier
* @param string $installationId Stable pseudonymous installation identifier
* @param int $schemaVersion Registered application schema version
* @param array{start:string,end:string} $period RFC3339 reporting period, start inclusive and end exclusive
* @param list<array{category:string,key:string,type:string,value:string|int|float|bool}> $metrics Aggregate metric values
*
* @return DataResponse<Http::STATUS_OK, array{status:string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_CONFLICT, array{error:string,message:string}, array{}>
*
* 200: Report accepted
* 400: Invalid report
* 409: Conflicting report for the reporting period
*/
#[PublicPage]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 60, period: 3600)]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/reports', requirements: ['apiVersion' => '(v1)'])]
public function create(
int $protocolVersion,
string $application,
string $installationId,
int $schemaVersion,
array $period,
array $metrics,
): DataResponse {
try {
$this->submission->submit([
'protocolVersion' => $protocolVersion,
'application' => $application,
'installationId' => $installationId,
'schemaVersion' => $schemaVersion,
'period' => $period,
'metrics' => $metrics,
]);
} catch (InvalidReport $e) {
return new DataResponse(['error' => 'invalid_report', 'message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (ConflictingReport $e) {
return new DataResponse(['error' => 'conflicting_report', 'message' => $e->getMessage()], Http::STATUS_CONFLICT);
}

return new DataResponse(['status' => 'accepted'], Http::STATUS_OK);
}
}
28 changes: 8 additions & 20 deletions lib/Controller/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,31 @@

namespace OCA\UsageStatisticsServer\Controller;

use OCA\UsageStatisticsServer\Db\ReportRepository;
use OCA\UsageStatisticsServer\Db\SchemaRepository;
use OCA\UsageStatisticsServer\Service\ConflictingReport;
use OCA\UsageStatisticsServer\Service\InvalidReport;
use OCA\UsageStatisticsServer\Service\ReportFactory;
use OCA\UsageStatisticsServer\Service\SchemaValidator;
use OCA\UsageStatisticsServer\Service\ReportSubmissionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

#[OpenAPI(tags: ['reports'])]
final class ReportController extends OCSController {
final class ReportController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private readonly ReportFactory $factory,
private readonly ReportRepository $repository,
private readonly SchemaRepository $schemas,
private readonly SchemaValidator $schemaValidator,
private readonly ReportSubmissionService $submission,
) {
parent::__construct($appName, $request);
}

/**
* Submit a usage statistics report
* Submit a usage statistics report as plain Protocol v1 JSON
*
* Stores one validated report for one application installation and reporting period.
* Repeating an already accepted report with the same schema version is idempotent.
Expand All @@ -60,7 +54,7 @@ public function __construct(
#[PublicPage]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 60, period: 3600)]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/reports', requirements: ['apiVersion' => '(v1)'])]
#[FrontpageRoute(verb: 'POST', url: '/api/{apiVersion}/reports', requirements: ['apiVersion' => '(v1)'])]
public function create(
int $protocolVersion,
string $application,
Expand All @@ -70,20 +64,14 @@ public function create(
array $metrics,
): DataResponse {
try {
$report = $this->factory->fromPayload([
$this->submission->submit([
'protocolVersion' => $protocolVersion,
'application' => $application,
'installationId' => $installationId,
'schemaVersion' => $schemaVersion,
'period' => $period,
'metrics' => $metrics,
]);
$schema = $this->schemas->find($report->application, $report->schemaVersion);
if ($schema === null) {
throw new InvalidReport('Application schema is not registered.');
}
$this->schemaValidator->validateReport($report, $schema);
$this->repository->store($report);
} catch (InvalidReport $e) {
return new DataResponse(['error' => 'invalid_report', 'message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (ConflictingReport $e) {
Expand Down
47 changes: 47 additions & 0 deletions lib/Service/ReportSubmissionService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\UsageStatisticsServer\Service;

use OCA\UsageStatisticsServer\Db\ReportRepository;
use OCA\UsageStatisticsServer\Db\SchemaRepository;

final class ReportSubmissionService {
public function __construct(
private readonly ReportFactory $factory,
private readonly ReportRepository $repository,
private readonly SchemaRepository $schemas,
private readonly SchemaValidator $schemaValidator,
) {
}

/**
* @param array{
* protocolVersion:int,
* application:string,
* installationId:string,
* schemaVersion:int,
* period:array{start:string,end:string},
* metrics:list<array{category:string,key:string,type:string,value:string|int|float|bool}>
* } $payload
*
* @throws InvalidReport
* @throws ConflictingReport
*/
public function submit(array $payload): void {
$report = $this->factory->fromPayload($payload);
$schema = $this->schemas->find($report->application, $report->schemaVersion);
if ($schema === null) {
throw new InvalidReport('Application schema is not registered.');
}

$this->schemaValidator->validateReport($report, $schema);
$this->repository->store($report);
}
}
Loading
Loading