Skip to content

Latest commit

 

History

History
197 lines (148 loc) · 8.18 KB

File metadata and controls

197 lines (148 loc) · 8.18 KB

Atomic Operations

This library implements the JSON API Atomic Operations extension. A client posts a batch of add, update, and remove operations in one request body. The server runs them all-or-nothing in one database transaction. It then answers with one result for each operation.

Contents

Responsibility split

The library owns You own
Parsing and validation of atomic:operations documents The route (one POST endpoint)
Dispatch of each type to its serializer Media-type enforcement (optional, helper provided)
Resolution of lid local identifiers Authorization policy
The transaction (begin, flush per operation, commit, roll back) Validation beyond the structure of the specification
atomic:results and error documents with pointers The EntityManager after a failed batch

Endpoint setup

use Gearsite\Api\Atomic\OperationsProcessor;
use Gearsite\Api\Atomic\ResultsDocument;

class OperationsController extends AbstractController {

    public function __construct(
        private readonly OperationsProcessor $processor,   // autowired by the bundle
        private readonly DocumentFactory $documentFactory,
        private readonly EntityManagerInterface $em,
    ) {}

    #[Route('/operations', methods: ['POST'])]
    public function operations(SymfonyRequest $request): Response {
        // The library does not enforce the media type. This check is recommended.
        if (!$request->hasAtomicExtension()) {
            return $this->documentFactory->error(415, new Error(status: '415', title: 'Unsupported Media Type',
                detail: 'Atomic operations require Content-Type ' . ResultsDocument::MEDIA_TYPE));
        }
        return $this->processor->process($request->getContent(), $this->em);
    }
}

If you enforce content negotiation, requests must carry this header:

Content-Type: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"

Responses use the same media type.

Walkthrough: create, link by lid, mutate a relationship

POST /operations
{
  "atomic:operations": [
    // 1. Create an author with a client-chosen local id.
    { "op": "add", "data": { "type": "author", "lid": "a1",
        "attributes": { "first-name": "Ada", "last-name": "Lovelace" } } },

    // 2. Create an article that references the author above. No real id is needed.
    { "op": "add", "data": { "type": "article", "lid": "art1",
        "attributes": { "title": "Notes", "body": "..." },
        "relationships": { "author": { "data": { "type": "author", "lid": "a1" } } } } },

    // 3. Append a comment to the to-many relationship of the article.
    { "op": "add", "ref": { "type": "article", "lid": "art1", "relationship": "comments" },
      "data": [ { "type": "comment", "id": "7" } ] }
  ]
}

A successful batch answers 200 with one result for each operation, in order. An operation with nothing to report returns {}:

{ "atomic:results": [
    { "data": { "type": "author",  "id": "17", "attributes": { ... }, "relationships": { ... } } },
    { "data": { "type": "article", "id": "42", ... } },
    {}
] }

When no operation produces content, the batch answers 204. An all-remove batch is the usual example.

Any error rolls the whole batch back. The response is then an errors document. Each source.pointer walks into the operation that failed, for example /atomic:operations/1/data/attributes/title. The members atomic:results and errors never appear together.

Relationship operations

ref.relationship targets one relationship instead of a whole resource.

To-one relationships:

  • update with an identifier sets the relationship.
  • update with data: null clears it.
  • add and remove are rejected with 400.

To-many relationships:

  • add appends members. Members that are already present are ignored.
  • update replaces the full membership.
  • remove removes the listed members. Members that are absent are ignored.

CAUTION: A relationship operation must target the owning side of a many-to-many association. Doctrine does not persist a change made on the inverse side.

Authorization

The atomic endpoint collapses your per-route access control into one POST. The processor therefore asks the serializer of each operation for permission first:

authorizeOperation(string $op, ?object $entity): bool

This method is closed by default. A type that declares nothing cannot be written through the atomic endpoint at all.

Tier 1 — static roles

This is the default implementation:

parent::__construct(type: 'transaction', repository: $repository, security: $security, operationRoles: [
    'add'    => ['ROLE_ACCOUNTING'],
    'update' => ['ROLE_ACCOUNTING'],
    'remove' => ['ROLE_ACCOUNTING_ADMIN'],
]);

Tier 2 — voters

To keep the atomic endpoint and your controllers in agreement, override the hook and delegate to the same voter. The policy then lives in one place:

// The override in the serializer. A two-line adapter, with no policy in it.
public function authorizeOperation(string $op, ?object $entity): bool {
    return $this->security?->isGranted(match ($op) {
        'add'    => ArticleVoter::CREATE,
        'update' => ArticleVoter::EDIT,
        'remove' => ArticleVoter::DELETE,
    }, $entity ?? Article::class) ?? false;
}
// The controller uses the same constants. One voter, two entry points.
#[Route('/articles/{id}', methods: ['DELETE'])]
#[IsGranted(ArticleVoter::DELETE, subject: 'article')]
public function delete(Article $article): Response { ... }

The voter holds the real rules, such as ownership, roles, and entity state:

class ArticleVoter extends Voter {
    public const CREATE = 'article.create';
    public const EDIT   = 'article.edit';
    public const DELETE = 'article.delete';

    protected function supports(string $attribute, mixed $subject): bool {
        return in_array($attribute, [self::CREATE, self::EDIT, self::DELETE], true)
            && ($subject instanceof Article || $subject === Article::class);
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool {
        return match ($attribute) {
            self::CREATE => $this->security->isGranted('ROLE_AUTHOR'),
            self::EDIT   => $subject->getAuthor()?->getUser() === $token->getUser()
                            || $this->security->isGranted('ROLE_EDITOR'),
            self::DELETE => $this->security->isGranted('ROLE_ADMIN'),
        };
    }
}

Secured members are strict in a batch

An atomic request means "apply exactly this, or nothing". A plain PATCH logs a warning and skips a secured member. A batch does not. An operation that contains a secured attribute or relationship fails the whole batch with 403. The error points at the member.

The same rule applies in two more cases:

  • An operation that targets a secured relationship through ref.relationship.
  • A relationship identifier that does not resolve. The batch fails with 404 instead of dropping the identifier.

Caveats

  • The EntityManager closes after a failed flush. When process() returns an error response, do not use that EntityManager again in the same request. If you must continue, call ManagerRegistry::resetManager().
  • Only the database rolls back. Mutators, hooks, and lifecycle listeners run for each operation inside the transaction. Work that they do outside the database is not undone. This includes mail, queues, and files. Keep external side effects out of every code path that a batch can reach.
  • Gaps in auto-increment values are normal. The database does not return the ids of rolled-back inserts to the sequence.
  • href targeting and composite keys are not supported.