apostrophecms / apostrophe-headless

Add REST APIs to your Apostrophe "pieces," powering your React/Vue/etc. apps with a headless CMS.
MIT License
98 stars 20 forks source link
hacktoberfest

CircleCI

Apostrophe as a headless CMS

Apostrophe is great for building websites, but many projects these days just need a "headless" CMS: an easy way to create new content types by defining schemas and immediately have a friendly interface for managing them on the back end... and REST APIs on the front end for React, React Native and other frontend frameworks to talk to.

Just as often, projects call for a mix of the two: Apostrophe as a CMS for the pages of the site, with React-style apps "mixed in" on certain pages.

The apostrophe-headless module provides REST APIs for content types created with Apostrophe's pieces feature. With this module, you might choose to just click "Page Settings" and lock down the "home page" of your site to "logged in users only," then use Apostrophe as a pure headless CMS... or you might mix and match. It's up to you.

We'll start out by talking about pieces, because they map so well to REST concepts. But apostrophe-headless also supports working with pages. We recommend you read about pieces first to figure out the basics, especially authentication.

Adding a REST API for products

Let's assume you have a module called products that extends apostrophe-pieces as described in our reusable content with pieces tutorial. Now you want a REST API so your app can easily get information about pieces.

Install the package

npm install apostrophe-headless

Turn it on

// in app.js
modules: {

  'apostrophe-headless': {},

  'products': {
    // Usually you'll put most of this in lib/products/index.js
    extend: 'apostrophe-pieces',
    name: 'product',
    // etc...
    restApi: true
  }
}

Configuration options

You can also pass options for the REST API:

  'products': {
    // etc...
    restApi: {
      // max 50 pieces per API result (the default)
      maxPerPage: 50,
      // Allow the public API to invoke additional
      // cursor filters. Note that most schema
      // fields have a cursor filter available
      safeFilters: [ 'slug' ],
      // Restrict GET routes to users with correct permission (false by default)
      getRequiresEditPermission: true
    }
  }
}

Setting maxPerPage high can have performance impacts. Consider designing your app with pagination or infinite scroll in mind rather than fetching thousands of pieces the user will not actually look at.

All of the documentation below discusses the products example above. Of course you may also configure the restApi option for other modules that extend pieces.

Workflow support

When used in the presence of the apostrophe-workflow, this module currently only supports GET requests. When making GET requests, you must specify the locale name of interest using the _workflowLocale query parameter, otherwise you will get results for the default locale only.

Note that this is not an issue if the document type in question is excluded from workflow via excludeTypes.

Retrieving all the products

Now your app can access:

/api/v1/products

To get the first page of products (50 per page, unless maxPerPage is adjusted as shown above). The response is JSON. See the results property for an array of products included in the first page, and the pages property for the total number of pages.

If you want to fetch a second page of products:

/api/v1/products?page=2

To avoid performance issues we do not send more than 50 products per API call. Your app should make additional queries as needed.

Filtering products

Here are some examples:

/api/v1/products?search=cheese

/api/v1/products?autocomplete=che

There's much more. You can use any s filter that offers a sanitize method via the query string. It's not hard to add custom filters if you need to, but keep in mind that most schema field types have built-in filter support.

To call most filters from the public API, you will need to use the safeFilters option to declare these filters "safe.". Rather than just restApi: true, write:

'my-module': {
  restApi: {
    // We're assuming here that you have added fields
    // called 'color' and 'brand' in your schema
    safeFilters: [ 'slug', 'color', 'brand' ]
  }
}

You may filter joinByOne and joinByArray fields, as long as they are listed in safeFilters. When doing so pass the _id property. Alternatively, leave the leading _ off the field name and pass the slug property.

Filtering fields for all requests

You can restrict what fields to send by adding api: false to a specific schema field. If only users with editing permissions for the doc should see a specific field, you can pass the option api: 'editPermissionRequired'.

{
  name: 'specificField',
  label: 'Specific Field',
  type: 'string',
  api: false
}

Filtering fields for a single request

You can also require only specific fields in the GET request by adding the query filters includeFields and excludeFields.

Examples:

/api/v1/products?includeFields=type,slug,name

// response example
[
  {
    _id: 'whatever_id',
    type: 'product',
    slug: 'product-key-product'
  }
]

The response will contain only _id, type, slug and (if present) name.

/api/v1/products?excludeFields=type,slug,name

// response example
[
  {
    _id: 'whatever_id',
    title: 'Product',
    body: { ... }
  }
]

The response will contain everything except type, slug and name.

It is useless to use both includeFields and excludeFields in the same query, as includeFields has priority over excludeFields. This is due to the way MongoDB projections work.

If there is any conflict between the api schema field option above and the includeFields option, the api schema field option takes priority.

Retrieving distinct tags, joins, etc. to populate your filters

In addition to fetching actual pieces, you can obtain information about the distinct tags that may exist on those pieces, as well as information about the distinct objects that are joined to them.

This is useful to populate your filters. For instance, to allow the user to filter the results by tag without prefetching every result in the database to scan for tags, you must know what tags exist.

To add information about distinct tags to the response, first configure your module to allow it:

// in lib/modules/products/index.js
'products': {
  extend: 'apostrophe-pieces',
  name: 'product',
  restApi: {
    safeDistinct: [ 'tags' ]
  }
}

Without safeDistinct, developers would be able to cause a denial of service by requesting all distinct values at once for fields like _id that are always different.

Now, you may access URLs like this:

/api/v1/products?distinct=tags

The response will look like:

{
  results: [ ... pieces here ],
  distinct: {
    tags: [
      {
        label: 'Free',
        value: 'Free'
      },
      {
        label: 'Paid',
        value: 'Paid'
      }
    ]
  }
}

Now we can display the labels to our users, and if they pick one, send back the value in the tags query parameter:

/api/v1/products?tags=Paid

Since the distinct values are intended for use as filters, use of safeDistinct implies safeFilter as well. You don't have to specify both for the same filter.

You can pass multiple values for tags, with or without the familiar [], syntax, for example: tags[]=one&tags[]=two You'll get results that include at least one of the tags.

Distinct values for joins

Now let's assume there is a joinByOne schema field called _specialist that joins our product piece with a specialist piece. We can fetch distinct values here too. In this case, the value property will be the _id:

// in lib/modules/products/index.js

'products': {
  name: 'product',
  extend: 'apostrophe-pieces',
  addFields: [
    {
      type: 'joinByOne',
      name: '_specialist'
    }
  ],
  restApi: {
    safeDistinct: [ '_specialist' ]
  }
}

Then we can access:

/api/v1/products?distinct=_specialist

The response will look like:

{
  results: [ ... pieces here ],
  distinct: {
    _specialist: [
      {
        label: 'Jane Doe',
        value: '_cyyyy'
      },
      {
        label: 'Joe Smith',
        value: '_czzzz'
      }
    ]
  }
}

Once again we can display the labels to our users, and if they pick one, send back the value in the _specialist query parameter:

/api/v1/products?_specialist=_cyyyy

We send the value, NOT the label. Again, you can send more than one by passing more than one _specialist query parameter. You'll get results that include at least one of the specialists.

Adding counts for each distinct value

Want to show the user how many items are tagged Free as part of your filter interface? You can do that by using distinct-counts in place of distinct. Keep in mind that the answer will still be in the distinct object; however, each choice will now have a count property in addition to label and value.

Example request:

/api/v1/products?distinct-counts=tags

Example response:

{
  results: [ ... pieces here ],
  distinct: {
    tags: [
      {
        label: 'Free',
        value: 'Free',
        count: 5
      },
      ... More tags here
    ]
  }
}

Distinct values for more than one filter

Yes, this is supported. Just use comma-separated field names when passing distinct or counts in your URL.

For example, you might make this request:

/api/v1/products?distinct=_specialist,tags

In which case the distinct property of the response will have both _specialist and tags subproperties.

Make sure both _specialist and tags are configured as safeDistinct:

// in lib/modules/products/index.js

'products': {
  name: 'product',
  extend: 'apostrophe-pieces',
  addFields: [
    {
      type: 'joinByOne',
      name: '_specialist'
    }
  ],
  restApi: {
    safeDistinct: [ '_specialist', 'tags' ]
  }
}

Access as a logged-in user

If you are accessing the API as a user who can edit this piece type, you can use all cursor filters intended for web use, otherwise only the filters marked safeFor: 'public'.

Retrieving one product

You can also retrieve one product via its _id property:

/api/v1/products/cxxxxxxx

The response is a single JSON object containing the product.

Even though you are fetching just one product, you can still invoke filters via the query string. If you are carrying out this request with the privileges of an admin user, you might want to add ?published=any to gain access to an unpublished product.

Inserting, updating and deleting products

These operations follow the usual REST patterns. But first, we need to talk about permissions.

Invoking APIs when logged out

This is simple: if the user is not logged in, they will be able to GET public, published content, and that's all.

For many apps, that's fine. You're using Apostrophe's admin bar to create the content anyway.

Your content editors log into a site that's just for content creation, and your app users pull content from it via REST APIs. Great! You're done here.

But for those who need to create and manage content via REST too... read on!

Invoking REST APIs as a logged-in user of your Apostrophe site

If you're building a React app or similar that is part of a webpage delivered by your Apostrophe site, and the right user is already logged into the site, then the APIs will automatically "see" the user and run with the right permissions. However, see the note that follows re: CSRF protection.

If this doesn't sound relevant to your project, skip ahead to learn how to use API keys and bearer tokens instead. We've got your back, headless horseman.

CSRF protection and logged-in users

If an API request comes from an Apostrophe user who logged in conventionally via the website, and not via the REST login APIs below, then Apostrophe will check for CSRF (Cross-Site Request Forgery) attacks.

If your API request is being sent by jQuery as provided by Apostrophe, you're good to go: Apostrophe automatically adds the necessary header.

If your API request is sent via fetch or another alternative to jQuery, you'll need to set the X-XSRF-TOKEN HTTP header to the current value of window.apos.csrfCookieName. This ensures the request didn't come from a sneaky form on a third-party website.

Adding CORS config

It's also possible to add a specific CORS configuration for headless routes. Note that this does not secure your routes against the use of scripts, curl, etc. It only prevents well-behaved browsers like Chrome from making unwanted cross-site requests. See available options here

    'apostrophe-headless': {
      cors: {
        // CORS options
      }
    },

Building apps without Apostrophe UI: bearer tokens and API keys

By default, the POST, DELETE and PUT APIs are available to logged-in users of the site. This is quite useful if you want to provide some editing features in a React or similar app that is part of your Apostrophe site.

But for a standalone app that uses Apostrophe as a headless backend, and isn't part of your Apostrophe site in any other way, logging in via Apostrophe's interface might not be an option.

For such cases, you can log in via REST and obtain a "bearer token" to be sent with requests. Or, you can use a hardcoded API key with total admin access. We'll look at API keys first, to help you get started. Then we'll look at bearer tokens.

Working with API keys

It's easy to configure API keys to have full admin access to all content for which the REST API has been activated:

// in app.js
modules: {
  'apostrophe-headless': {
    apiKeys: [ 'example-i-sure-hope-you-changed-this' ]
  },
  products: {
    extend: 'apostrophe-pieces',
    name: 'product',
    restApi: true
  },
  locations: {
    extend: 'apostrophe-pieces',
    name: 'location',
    restApi: true
  }
}

You can also configure api keys for a single module:

// in app.js
modules: {
  'apostrophe-headless': {
    // This option MUST EXIST to allow api keys at all. If you
    // do not want any global api keys, leave it empty
    apiKeys: []
  },
  products: {
    extend: 'apostrophe-pieces',
    name: 'product',
    restApi: true,
    apiKeys: [ 'i-only-grant-access-to-this-one-module' ]
  },
  locations: {
    extend: 'apostrophe-pieces',
    name: 'location',
    restApi: true
  }
}

Either way, the api key is allowed to create attachments (see Images, files and attachments in REST).

Now you can pass the API key in either of two ways when inserting a product or making a similar request:

  1. Just add an apikey property to the query string. This goes in the query string regardless of the request method.

Example:

POST /api/v1/products?apikey=example-api-key

The body of the POST may be a JSON body or use the traditional url encoding, as described below; the important thing is that the apikey is separate, in the query string, as shown here.

  1. Pass an Authorization header as part of your HTTP request:

Authorization: ApiKey your-api-key-goes-here

Always secure sites that accept API keys with HTTPS. You should never send an API key over "plain HTTP." Of course, browsers are starting to deprecate sites that don't accept HTTPS anyway!

When NOT to use API keys

API keys are useful for hardcoded situations where there is no way an untrusted user could ever see them. For instance, it's fine to use an API key for back-end communication between two servers.

However, you should never use api keys in the code of a mobile app, browser-based web app, JavaScript in the browser of any kind or other situation where code might be viewed as source, decompiled, etc. In these situations, you must use bearer tokens, which are specific to a user.

Using bearer tokens

Bearer tokens are a way to let users log in even though they never see an Apostrophe-powered website. They allow you to implement your own login mechanism in your mobile app.

Using bearer tokens only makes sense if you are using Apostrophe as your authentication system. If you are using apostrophe-passport to connect Apostrophe to google login, Twitter login, etc., you'll need to log users in via the Apostrophe site and then deliver your app via a stripped-down Apostrophe "home page" on that site. See the notes above re: working smoothly with our CSRF protection in this configuration.

How to log users in with bearer tokens

  1. Turn on support for bearer tokens:
// in app.js
modules: {
  'apostrophe-headless': {
    bearerTokens: true
  }
}

By default bearer tokens last 2 weeks, which is very secure but can be frustrating for casual apps that don't contain sensitive data. Here's how to set the bearer token lifetime:

// in app.js
modules: {
  'apostrophe-headless': {
    bearerTokens: {
      // 4 weeks, in seconds
      lifetime: 86400 * 7 * 4
    }
  }
}
  1. Send a POST request to:

/api/v1/login

With username and password properties in the body.

  1. On success, you will receive a JSON object with a single property: bearer.

  2. For all of the REST API calls that follow, pass that value as the Authorization header, preceded by Bearer and a space:

Bearer nnnn

Where nnnn should be replaced with the value of the bearer property you received.

There is no need to pass the XSRF header when using a valid bearer token because bearer tokens are never part of an Apostrophe session.

  1. If you receive a 401 Unauthorized response to a later API request, consider making another login call to obtain a new bearer token. The expiration of bearer tokens depends on the expires setting as shown earlier.

  2. If the user logs out of your app, send a POST request as follows:

/api/v1/logout

With the appropriate Bearer heading as for any other request. That bearer token will be invalidated.

Always secure sites that accept bearer tokens with HTTPS. Of course, browsers are starting to deprecate sites that don't accept HTTPS anyway!

If you submit an invalid or outdated bearer token for any request, you will receive a 401 HTTP status, and a JSON object with an error property set to 'bearer token invalid'. This is your cue to ask the user to log in again and then retry the request.

Login events

You can find an apostrophe-headless:beforeLogin promise event which is emitted with (req) before a login attempt is evaluated.

Inserting a product

You can insert a product via a POST request. You should POST to:

/api/v1/products

The body of your POST should contain all of the schema fields you wish to set.

You may use either traditional URL-style encoding or a JSON body. However if you are working with Apostrophe areas you must use a JSON body (see below).

On success you will receive a 200 status code and a JSON object containing the new product.

Updating a product

To update a product completely, sending all the data again, make a PUT request. Send it to:

/api/v1/products/cxxxxxxx

Where cxxxxxxx is the _id property of the existing product you wish to update.

On success you will receive a 200 status code and the updated JSON object representing the product.

You may use either traditional URL-style encoding or a JSON body. However if you are working with Apostrophe areas you must use a JSON body (see below).

If you want to update just SOME of the properties, without the risk that some of your other data is incomplete or out of date, use PATCH (see below).

Patching a product

To patch a product partially, sending only the changes, make a PATCH request. Send it to:

/api/v1/products/cxxxxxxx

Where cxxxxxxx is the _id property of the existing product you wish to patch. Use the PATCH HTTP method.

Include only the properties you wish to change. If a property is present in your request body, it will be updated. If it is present, but empty, it will be updated to an empty value, which may or may not be accepted depending on your schema.

On success you will receive a 200 status code and the updated JSON object representing the entire product.

You may use either traditional URL-style encoding or a JSON body. However if you are working with Apostrophe areas you must use a JSON body (see below).

Patching just part of an array property

You may also PATCH an array property without re-sending the entire array. apostrophe-headless supports several operators based on the MongoDB operators of the same name.

To use this feature, you MUST use a JSON body, not traditional URL-style encoding.

If your schema includes this field:

{
  name: 'addresses',
  type: 'array',
  schema: [
    {
      name: 'street',
      type: 'string'
    }
  ]
}

Then you may carry out the following operations:

$push: append one

{
  $push: {
    addresses: {
      street: '103 Test Lane'
    }
  }
}

$push with $each: append many

{
  $push: {
    addresses: {
      $each: [
        {
          street: '104 Test Lane'
        },
        {
          street: '105 Test Lane'
        },
        {
          street: '106 Test Lane'
        },
      ]
    }
  }
}

$pullAll: remove array entries matching complete value

{
  $pullAll: {
    addresses: [ addresses[0] ]
  }
}

$pullAllById: remove array entries matching id or _id property

$pullAllById: {
  addresses: [ addresses[0].id ]
}

"But where do I get addresses[0].id from?" Typically from an earlier GET or POST operation.

Array operators can be used to manipulate array schema fields, the widget array of an area, or the idsField of a join.

Deleting a product

To delete a product, make a DELETE request. Send it to:

/api/v1/products/cxxxxxxx

Where cxxxxxxx is the _id property of the existing product you wish to delete.

The response will be an appropriate HTTP status code.

Inserting areas and widgets via REST

Given how powerful they are, areas and widgets in Apostrophe are surprisingly easy to work with via the REST API.

Just bear these facts in mind:

Here's an example of a simple area containing a standard apostrophe-rich-text widget, a "nav" widget specific to a particular site which contains an array schema field, and a standard apostrophe-images widget:

body: {
  type: 'area',
  items: [
    {
      id: 'cxxxxx1',
      type: 'apostrophe-rich-text',
      content: '<h4>Subheading</h4><p>Here is some text.</p>'
    },
    {
      id: 'cxxxxx2',
      type: 'nav',
      links: {
        type: 'array',
        items: [
          {
            id: 'cxxxxx3',
            url: 'http://cnn.com',
            label: 'CNN'
          },
          {
            id: 'cxxxxx4',
            url: 'http://google.com',
            label: 'Google'
          },
        ]
      }
    },
    {
      id: 'cxxxxx5',
      type: 'apostrophe-images',
      by: 'id',
      pieceIds: [ 'imageid1', 'imageid2' ]
    }
  ]
}

We'll see how pieceIds works in the apostrophe-images widget in a moment when we discuss images, files and attachments in REST.

Joins in REST

When retrieving pieces, joined content is included, via the join field's name, as you might expect.

When inserting or updating pieces, it is possible to set a join. You will need to set the idField (for joinByOne) or idsField (for joinByArray) corresponding to the join. If you did not explicitly configure these when configuring the join in your schema, they are based on the name of the join:

_stores -> storeIds

_owner -> ownerId

etc. Set that property to the appropriate ID or array of IDs.

Images, files and attachments in REST

It is possible to attach files to a new or updated piece. To do so you will first need to understand how attachments work in Apostrophe. In most cases, you'll also need understand how apostrophe-images and apostrophe-files widgets work.

Attachment fields

attachment is a special schema field type. Ideally, files attached to a piece would live right inside it. However since files are large and it does not make sense to resend the same file every time you update a piece, you will instead need to first send Apostrophe the file and obtain an attachment object. You can then use that attachment object as the value of any field of type attachment. Think of the attachment as a "pointer" to the real file on disk.

To send an attachment, POST a file (using the multipart/form-data encoding) to the following URL:

/api/v1/attachments

Send the actual file as the file field in your form submission.

The user POSTing the attachments must have the edit-attachment permission. POST is currently the only method provided for attachments.

On success, you will receive a JSON object containing properties similar to these:

{
  _id: 'attachmentidnnnn',
  width: 500,
  height: 400,
  group: 'images',
  extension: 'jpg',
  name: 'cleaned-up-name-without-extension'
}

The content-type of the response will be text/plain, for backwards compatibility with certain browsers, but it will contain valid JSON.

You can now send this object as the value of any attachment schema field when communicating with the REST API.

Using attachments directly

If you're doing most of your editing through the REST API, or your content types don't really need a shared image library from which images can be chosen by the end user, you might just add a schema field like this in your module:

addFields: [
  {
    type: 'attachment',
    name: 'snapshot',
    // Accepts only images. Can also specify `office`
    // to accept workplace document formats
    groups: [ 'images' ]
  }
]

Then you can simply pass the file object you received from the attachments API as the snapshot property when POSTing a product.

Later, when you GET this product from the API, you'll note that the attachment has a ._urls property with versions of various sizes for your use. To make those URLs absolute, set the baseUrl option for your site in app.js. This is a top-level option, like shortName. It does not belong to a specific module. It should be set to the URL of your site, without any path part. In production, that might look like http://example.com while in development, it might look like: http://localhost:3000

Working with the shared media library

Sometimes, you'll want to introduce an image to the shared media library of Apostrophe and reference it via an images widget. Here's how to do that.

Working with apostrophe-images and apostrophe-files

Often you'll use a widget of type apostrophe-images or apostrophe-files to display a slideshow of images, or a download button for a file. This allows the user to choose them from a shared media library. If you're doing at least some of your editing through Apostrophe then this is an attractive option.

So if you want to create these widgets with the REST API, you'll need to first use the technique above to create an attachment.

Here we're assuming a singleton field called thumbnail containing an apostrophe-images widget is part of your schema for projects. In the database, both areas and singletons are simply stored as areas. The only difference is that the end user can't put more than one widget in a singleton via the editor.

So, make sure you turn on the REST API for apostrophe-images too. Images are pieces in their own right:

// in app.js
modules: {
  'apostrophe-images': {
    restApi: true
  },
  // etc
}

Note that the user POSTing these images must have edit permission for both images and products.

Now, POST to /api/v1/apostrophe-images. You'll need to supply at least title, slug, and attachment. The attachment field must contain the file object you received from the attachment upload API, above.

Just set attachment to result, where result is the JSON object you got back from the upload API.

You will receive a JSON object in response. Using the _id property, you can create a project that includes that file in an images widget, in an area called thumbnail. POST an object like this to /api/v1/projects to create a project with a thumbnail:

{
  title: 'My Project',
  slug: 'my-project',
  thumbnail: {
    type: 'area',
    items: [
      {
        type: 'apostrophe-images',
        by: 'id',
        pieceIds: [ yourImageId ]
      }
    ]
  }
}

Set yourImageId to the _id of the object you received when you POSTed to /api/v1/apostrophe-images.

Working with pages

The examples above all concern pieces. Pieces are the most natural candidate for a REST API, but you can also use apostrophe-headless to work with pages:

modules: {

  'apostrophe-headless': {},

  'apostrophe-pages': {
    restApi: true
  }
}

Retrieving the home page and its children

Now your app can access:

/api/v1/apostrophe-pages

To get information about the home page and its children. The response is a single JSON object with slug, path, title, type, _url and other properties describing the home page, similar to the way pieces are returned (see the "products" examples above). In addition, information about children of the home page is returned.

Accessing child pages

Basic information about the top-level children of the home page (aka the "tabs" of your site) is available in the _children property of the returned object. This property is an array. Each element has, at a minimum, _id, title, type and slug properties.

By default, the _children property always exists. It may be empty. You can disable this property for a smaller response by including the query parameter, children=false.

Fetching detailed information about one page

Armed with the _id, you can obtain detailed information about a page by making a separate API request:

/api/v1/apostrophe-pages/ID_GOES_HERE

A page returned in this way will in turn offer its own _children property.

This response will include schema fields, areas, etc. in the same detail as it would when requesting a piece.

Accessing ancestor pages

Pages also have an _ancestors array. This functions similarly to the _children array. The first entry is the home page, and the last entry is the immediate parent of the page in question.

By default, the _ancestors property also always exists. It may be empty. You can disable _ancestors for a smaller response by including the query parameter, ancestors=false.

Obtaining the entire page tree with a single request

It is possible to obtain summary information about the entire page tree with a single request. Since the unrestricted use of this feature could have a performance impact, This feature requires a bearer token or API key.

If a bearer token is used, the returned tree will not contain pages to which the user does not have edit access, except for ancestors of pages to which the user does have edit access, which is necessary to accurately present the tree.

To fetch the entire tree, add all=1 to your query:

/api/v1/apostrophe-pages?all=1

Nested tree response

The response will be a single object representing the home page, with at least title, slug, tags, _url and _id properties, and a _children array. For speed, the response will not be as detailed as in a regular request to /api/v1/apostrophe-pages.

The pages in the _children array, in turn, will feature their own _children arrays where needed, with a similarly limited level of detail.

Flat response

It is possible to obtain a flat version of this data by adding ?flat=1 to the URL. In this case, a flat JSON array is returned. The array is sorted by depth, then by rank. Pages may still have a _children array, however it will only contain the _ids of the child pages, not the pages themselves. In this way you can still reconstruct the tree if you wish.

Inserting a page

All write operations to pages are governed by permissions. See "invoking APIs when logged out," above. You will need to use an API key or bearer token.

It is possible to insert a page via the API:

/api/v1/apostrophe-pages

The body of your POST should contain all of the schema fields you wish to set, and in addition it must contain a _parentId property (note the underscore). The page will be added as the last child of the specified parent page.

The use of a JSON body, rather than traditional URL encoding, is strongly recommended and if you are working with areas it is mandatory.

On success you will receive a 200 status code and a JSON object containing the new page.

If you wish to insert or update areas, they must be present in the schema of the page type.

Updating a page

To update a product, make a PUT request. Send it to:

/api/v1/apostrophe-pages/cxxxxxxx

Where cxxxxxxx is the _id property of the existing page you wish to update.

On success you will receive a 200 status code and the updated JSON object representing the product.

You may use either traditional URL-style encoding or a JSON body. However if you are working with Apostrophe areas you must use a JSON body (see below).

You may not move a page in the page tree via this method. The path, level and rank properties cannot be modified by this method. To move a page in the page tree, see "moving a page in the page tree," below.

If you wish to insert or update areas, they must be present in the schema of the page type.

Deleting a page

To delete a page, make a DELETE request. Send it to:

/api/v1/apostrophe-pages/cxxxxxxx

Where cxxxxxxx is the _id property of the existing page you wish to delete.

The response will be an appropriate HTTP status code.

For consistency with the rest of Apostrophe, a deleted page is moved to the trash.

Moving a page in the page tree

To move a page in the page tree, make a POST request to the following URL:

/api/v1/apostrophe-pages/ID-OF-PAGE/move

Your POST body must contain the following fields:

The home page and other "parked" pages may not be moved.

Rendering full pages and page fragments

Ordinarily, the API simply returns the content of the page or piece as a JSON data structure. Sometimes, you'd like rendered markup.

Rendering a full page experience

If you just want the full page representation of a page or piece, rendered as Apostrophe would normally do it, use the API to fetch information about that page or piece, and then separately request the URL in its ._url property.

If you make that request from a browser, it will be detected as an AJAX (“xhr”) request, and the outermost markup of the page (styles, script tags, etc.) will not be returned, just the portion inside the div with the apos-refreshable class. You can also get this effect in a non-browser request by setting the apos_refresh=1 query parameter. Otherwise the page is fully rendered, including assets.

Rendering a page or piece as an HTML fragment

If you wish to render just a fragment of HTML, read on to see how you can create your own templates specifically for use with the API. This is the best approach when Apostrophe content is just one part of the page or experience you are building.

Let's return to the "products" example and create a Nunjucks template to be rendered by the API:

{# In lib/modules/products/views/api/fragment.html #}

{# Let's output the title of the piece #}
<h4>{{ data.piece.title }}</h4>
{# Now let's render an area as Apostrophe normally would #}
{{ apos.area(data.piece, 'body') }}
{# On second thought, let's just render the first image in that area directly #}
{% set image = apos.images.first(data.piece, 'body') %}
{% if image %}
  <img src="https://github.com/apostrophecms/apostrophe-headless/raw/main/{{ apos.attachments.url(image, { size: 'one-half' }) }} " />
{% endif %}

Now let's configure the products module to allow rendering of the api/fragment.html template:

// in app.js, building on your configuration of products earlier
  'products': {
    extend: 'apostrophe-pieces',
    name: 'product',
    // etc...
    restApi: true,
    apiTemplates: [ 'fragment' ]
  }

You will now receive this fragment of HTML as part of the render property of a product retrieved from the API, as long as you ask for it as part of your GET REST API request:

/api/v1/products/ID-OF-PRODUCT-GOES-HERE?render=fragment

Notice we have added render=fragment to the query string, to specifically ask that api/fragment.html be rendered.

Now the response will look like:

{
  _id: "ID-OF-PRODUCT-GOES-HERE",
  title: "Cool Product",
  rendered: {
    fragment: "<h4>Cool Product</h4>... more markup ..."
  }
}

You can render more than one, by passing more than one value for render. The resulting URL will look like this: ?render[]=fragment&render[]=other

If you're using qs or another good query string builder, you won't have to worry about building that yourself. Just pass an array of template names as render.