Nextcloud provides a large public PHP interface to developers to use for
building their application. It is commonly called OCP. Some of big components
of OCP are the HTTP stack with
IRequest,
Response
and
Controllerto
handle requests, the
IQueryBuilder/IDBConnection
to query the database and many feature oriented components and utilities.
Outside of OCP, applications can also use a lot of private APIs and 3rd party
libraries included by Nextcloud, but these don’t have the same stability
guarantees as the official public interface.
With Nextcloud Hub 26 Summer (35.0.0), there are 3 big changes coming to the OCP
API. The first two are that we are now providing a public API for the Symfony
Console component and
the Doctrine database schema abstraction.
The first one is required when wanting to extend the Nextcloud command line tool
occ and
the second one is used to create database migrations. Both are very important
but would break every app every time we would update the dependencies, which is
forcing us to keep older versions of these dependencies (thankfully older
versions are still maintained).
Declaring Commands with #[AsCommand]
To replace the first one, we added a new family of PHP attributes and interfaces.
Instead of inheriting from OC\Core\Command\Base to implement a command, you can now
use a simple PHP invokable class and annotate it with the #[AsCommand] (link)
attribute as follows.
#[AsCommand(
name: 'app:create-user',
description: 'Creates a new user.',
help: 'This command allows you to create a user...',
usages: ['bob', 'alice --as-admin'],
)]
class CreateUserCommand {
public function __invoke(): ExitCode {
// ...
return ExitCode::Success;
}
}
Options and arguments for your commands can be defined declaratively in the
__invoke method parameters.
The parameter’s type and default value decide whether the argument or option is required, repeatable, or a flag.
#[AsCommand(name: 'app:user:created')]
class CreateUserCommand {
public function __invoke(
#[Argument(description: "The username of the user")]
string $userId,
#[Option(description: "Force the creation")]
bool $force = false,
): ExitCode {
// ...
return ExitCode::Success;
}
}
Additionally it is possible to inject
IOutput
and
IInput
in the __invoke method parameters to be able to ask questions; print texts,
progress bars and tables.
This new API doesn’t replace the existing private API, which is still available, but
using it allows you to improve the coverage of the static analyser as stubs for these
commands are available in the nextcloud/ocp
package and this will prevent you from API breakage when using the Symfony Console API
directly.
Manipulating SQL Tables with OCP\DB\Schema
This is actually a small breaking change, but for the schema migration,
ISchemaWrapper
won’t return Doctrine\DBAL types anymore but instead our own wrapper in OCP\DB\Schema.
The wrapper has essentially the same API as the doctrine implementation so the breaking change should be minimal. But there are a few cases which won’t work anymore, for example:
- Type hinting methods with a DBAL type where the type needs to be replaced
with the new
OCPtype or use a union type if you want to support multiple versions. Type::lookupName($column->getType())->$column->getType()->getName()
Fortunately all these issues should be found quite easily by installing your application, which should be the case when running your unit tests. Grepping for DBAL and running psalm/phpstan should also help find the issues.
Introducing the Nextcloud ORM
The third big change is the addition of a simple ORM to Nextcloud: OCP\AppFramework\ORM.
This allows you to define an easy mapping between your database tables and PHP objects by using PHP attributes. This can be used for simple tables.
<?php
use OCP\AppFramework\ORM\Attribute\Column;
use OCP\AppFramework\ORM\Attribute\Entity;
use OCP\AppFramework\ORM\Attribute\Id;
use OCP\DB\Schema\ColumnType;
#[Entity(name: 'twofactor_backupcodes')]
final class BackupCode {
#[Id]
#[Column(name: 'id', type: ColumnType::Integer, nullable: false)]
public ?int $id = null;
#[Column(name: 'user_id', type: ColumnType::String, length: 64, nullable: false)]
public string $userId;
#[Column(name: 'code', type: ColumnType::String, length: 128, nullable: false)]
public string $code;
#[Column(name: 'used', type: ColumnType::Smallint, nullable: false, default: 0)]
public int $used = 0;
}
Which can then be fetched by using a Repository which provides convenient utilities
to fetch, delete, update or insert entries in the database.
<?php
class BackupCodeRepository extends Repository {
public const string entityClass = BackupCode::class;
/**
* @return \Generator<BackupCode>
*/
public function findByUser(IUser $user): \Generator {
return $this->findBy([
'userId' => $user->getUID(),
]);
}
public function deleteByUser(IUser $user): void {
$this->deleteBy([
'userId' => $user->getUID(),
]);
}
}
$backupCodeRepo = Server::get(BackupCodeRepository::class);
$backupCode = new BackupCode();
$backupCode->userId = 'admin';
$backupCode->code = 'secret';
$backupCode = $backupCodeRepo->insert($backupCode);
$backupCode->code = 'new-code';
$backupCodeRepo->update($backupCode);
$adminCodes = $backupCodeRepo->findBy(['userId' => 'admin']);
$backupCodeRepo->delete($backupCode);
The ORM also supports mapping simple relationships between tables. For example,
to define a relation between a customer and a cart where each cart has a
customer and each customer has a cart, you can use the following annotated PHP
classes. In the background, Nextcloud will create a SQL query with a JOIN
automatically to fetch all the information in one query.
#[Entity(name: 'repository_customer')]
final class Customer {
#[Id]
#[Column(name: 'id', type: ColumnType::Bigint)]
public ?int $id = null;
#[OneToOne(targetEntity: Cart::class, mappedBy: 'customer')]
#[JoinColumn(name: 'cart_id', referencedColumnName: 'id')]
public ?Cart $cart = null;
#[Column(name: 'name', type: ColumnType::String, nullable: false)]
public string $name;
}
#[Entity(name: 'repository_cart')]
final class Cart {
#[Id]
#[Column(name: 'id', type: ColumnType::Bigint)]
public ?int $id = null;
#[OneToOne(targetEntity: Customer::class, invertedBy: 'cart')]
#[JoinColumn(name: 'customer_id', referencedColumnName: 'id')]
public ?Customer $customer = null;
}
For now, this only supports OneToOne and ManyToOne relationships between classes,
with OneToMany and ManyToMany still missing.
A good example how this simplifies the code is this pull request porting the oauth2 app to this entity system.
Improved HTTP dispatcher
The HTTP dispatcher which takes care of calling the correct controller method now does various levels of input sanitization based on the PHPDoc comments. This means you can now write the following and have some guarantees about some of your variables:
<?php
class MyController extends OCSController {
/**
* @param non-empty-string $search
* @param positive-int $limit Maximum number of results to return
* @param non-negative-int $offset Offset for searching
* @return DataResponse<Http::STATUS_OK, list<CoreAutocompleteResult>, array{Link?: string}>
*
* 200: Autocomplete results returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/autocomplete/get', root: '/core')]
public function autocomplete(string $search, int $limit = 10, int $offset = 0): DataResponse {
// $search is now guaranteed to be non empty
// $limit is now guaranteed to be > 0
// $offset is now guaranteed to be >= 0
...
}
}
You can also inject backed enums and the values for this parameter will be restricted to one of the enum values.
<?php
enum GrantType: string {
case AuthorizationCode = 'authorization_code';
case RefreshToken = 'refresh_token';
}
class MyController extends OCSController {
#[PublicPage]
public function getToken(
GrantType $grant_type, ?string $code, ?string $refresh_token,
?string $client_id, ?string $client_secret,
): JSONResponse {
...
}
}
These two changes make it easier for you to ensure your APIs are secure by default!
Other Small Changes
We now expose the PHP polyfill in the
nextcloud/ocpwhich makes it easier for the tooling to pick them up. For example, you can now use the PHP 8.6\SortDirectionenum in your applications while PHP 8.6 is not even out yet. And we also added support for this enum in theIQueryBuilder::addOrderBymethod.OCP\DB\ColumnTypeas a modern enum replacement for theOCP\DB\Typeswhich is a proper PHP enum and not a class with public constants.
What Is Coming Next?
In the next releases, I want to make Data Transfer Objects (DTO) even more powerful in Nextcloud. The new Entity classes are good examples of DTOs, as they are used to transfer data. What is still missing is a good strategy to serialize/deserialize them to JSON (and other formats) and also to validate them.
For this goal, we would want to wrap the already existing components from Symfony and just provide our own attributes. For reference, here’s what this looks like in Symfony, and what I’d really like to see in Nextcloud at some point.
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Author {
#[Assert\Email(
message: 'The email {{ value }} is not a valid email.',
)]
protected string $email;
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public \DateTimeImmutable $createdAt;
#[Ignore]
public function isPotentiallySpamUser(): bool { ... }
}