The Generalized CRUD API of PSPDFKit for Web

Illustration: The Generalized CRUD API of PSPDFKit for Web

PSPDFKit for Web 2020.5 introduced a new, refactored CRUD API for working with various domain object types in a unified fashion. This new API is simpler to use and more performant than the previous offering. In this blog post, I’ll outline the motivation behind its introduction and explain the main benefits in more detail.

What Is the CRUD API?

PSPDFKit for Web supports various types of domain objects that can be created, updated, and deleted. There’s also a dedicated API to save and assess the saved state of these objects.

Objects that are supported by this API include:

  • Annotations, which are objects that can be added to PDF pages without changing the page content. These include text highlights, notes, shape drawings, and more.
  • Bookmarks, which provide an easy way to jump to pages or trigger other actions, like opening a web page.
  • Form fields, which are interactive elements that can be viewed, filled out, and submitted. Examples are text, signature, or combo box fields and various kinds of buttons.
  • Form field values, which represent values of filled form fields.
  • Comments, which allow easy collaboration on documents, as multiple users can start discussions on specific sections.

ℹ️ Note: In the remainder of this blog post, I’ll use the acronym CRUD to refer to this API. I won’t be covering the read (R in the CRUD acronym) operations in detail since they weren’t included in this refactoring.

Motivation

The main bulk of PSPDFKit for Web’s public API is exposed on the Instance class that represents a loaded document. Prior to version 2020.5, the CRUD API consisted of distinct methods for each object type. For example, here are the methods related to annotations:

Copy
1
2
3
4
5
6
7
8
9
10
class Instance {
    ...
    createAnnotation(annotation: Annotation): Promise<Annotation>
    updateAnnotation(annotation: Annotation): Promise<Annotation>
    deleteAnnotation(annotationId: ID): Promise<void>

    hasUnsavedAnnotations(): boolean
    saveAnnotations(): Promise<void>
    ensureAnnotationSaved(annotation: Annotation): Promise<Annotation>
}

As you can see, exposing all of these methods for each object type led to a wide API surface and a lot of code needed to wire them up with the backend. This meant more work when introducing new object types and while maintaining our existing code.

This wasn’t the only issue we had with this API. Another big one was that of flexibility, mainly when trying to create batches of objects. This could lead to UI performance issues and unnecessary network usage when saving the changes to the server backend using multiple requests instead of a single one.

Design Goals

We decided to resolve the technical debt of our former CRUD API by providing a brand-new set of APIs that would solve these issues and provide a more maintainable and future-proof solution.

There were a few design goals we set for ourselves when designing this new CRUD API, including:

  • The unification of the existing CRUD methods to decrease the API surface, increase the testability, and lower the maintenance costs by generalizing and simplifying the internal implementation.
  • Batch operations for potentially better performance and optimized network usage.
  • Simplifying error handling when creating objects with dependencies.
  • Extending existing error reporting for more detailed error handling for API consumers.

Unified CRUD API

We introduced the Change type to abstract supported object types:

1
export type Change = Annotation | Bookmark | FormField | Comment;

We also added a set of generalized CRUD methods:

Copy
1
2
3
4
5
6
7
8
9
10
class Instance {
    ...
    create(changes: Change | Array<Change>): Promise<Array<Change>>
    update(changes: Change | Array<Change>): Promise<Array<Change>>
    delete(changeIds: ID | Array<ID>): Promise<Array<Change>>

    hasUnsavedChanges(): boolean
    ensureChangesSaved(changes: Change | Array<Change>): Promise<Array<Change>>
    save(): Promise<void>
}

These methods support a single Change or an Array of Change objects, thereby enabling batched updates. Each of them resolves with an array of resolved changes (created/updated/deleted/saved). Operations are performed on a best-effort basis, even if some batched changes can’t be executed. In such a case, the methods reject with an array containing the resolved changes or errors for failed changes.

Note that we decided to use a consistent Array result type instead of resolving to a singular result when only a single change was used as a parameter, in order to ensure the API was less confusing.

Additionally, new methods are composable. For example, it’s straightforward to implement code that makes sure certain changes get saved in a (server) backend:

Copy
1
2
3
4
5
6
instance
  .create(newAnnotations)
  .then(instance.ensureChangesSaved)
  .then(function(savedAnnotations) {
    // Annotations are now guaranteed to be saved.
  });

Creating Dependent Objects

Support for batching changes allowed us to fix the creation of objects with dependencies, which was one of the pain points of the former CRUD API.

An example of this issue is that of widget annotations that must be created together with their associated form fields. We were forced to put a workaround in place that removed an orphaned widget annotation if the form field wasn’t created in 500 ms.

With the new unified CRUD API, we’re now ensuring widget annotations are created together with their associated form fields in order to prevent the aforementioned issue.

For example:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const widget = new PSPDFKit.Annotations.WidgetAnnotation({
  id: PSPDFKit.generateInstantId(),
  pageIndex: 0,
  formFieldName: "MyFormField",
  boundingBox: new PSPDFKit.Geometry.Rect({
    left: 100,
    top: 75,
    width: 200,
    height: 80
  })
});
const formField = new PSPDFKit.FormFields.TextFormField({
  name: "MyFormField",
  annotationIds: new PSPDFKit.Immutable.List([widget.id]),
  value: "Text shown in the form field"
});

// The following call will fail because we're creating an orphaned widget.
// instance.create(widget)

// Widgets and form fields must be created in one batch operation when using the unified CRUD API.
instance.create([widget, formField]);

Performance Optimizations

Batched CRUD operations allow us to optimize performance, as the UI only needs to rerender once while applying multiple changes.

This could have been implemented on the call side — for example, by batching state changes. But this required that the consumer of our API understood PSPDFKit’s internals, while the unified CRUD API abstracts these implementation details away.

One particular example of these performance optimizations is apparent when creating hundreds of annotations from text search. Creating this many annotations one by one resulted in an unacceptably long UI freeze. This is a fairly common use case, for instance, when including highlighting or redacting a specific phrase in a document:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const results = await instance.search("the");

const annotations = results.map(result => {
  return new PSPDFKit.Annotations.RedactionAnnotation({
    pageIndex: result.pageIndex,
    rects: result.rectsOnPage,
    boundingBox: PSPDFKit.Geometry.Rect.union(result.rectsOnPage)
  });
});

// Creating changes one by one freezes the UI.
// annotations.forEach(annotation => instance.createAnnotation(annotation))

// Creating them in a batch leads to a responsive UI.
instance.create(annotations);

On top of UI performance optimizations, we also optimized network usage by bundling together requests for all operations performed in a batch. This means lower data usage everywhere and lower battery impact on mobile devices.

Finally, we adapted the event system to fire events (such as annotations.update) once for an entire batch operation. This makes it possible to batch UI updates when consuming an event, resulting in additional performance gains.

Migration

Here at PSPDFKit, we have a policy to not break our customers’ code without prior warning. This is why PSPDFKit for Web 2020.5 still ships with both the former (now deprecated) and the new unified CRUD APIs. Deprecated methods will still work until the next major release, so our customers will have time to update by following our migration guide.

Conclusion

The new unified CRUD API described in this post makes implementation of object manipulation operations easier, and it reduces the chance of errors. In addition, it simplifies the internal architecture of PSPDFKit for Web, resulting in improved maintenance and fewer potential bugs. I hope this post provided some insight into our reasoning behind the introduction of this major API change and that I could convince you of its benefits for both customers of PSPDFKit for Web and for us, the development team.

PSPDFKit for Web

PDF viewing, annotating, and collaboration for web apps.

Try Now