Skip to content

Commit

Permalink
Merge pull request #6 from sanity-io/projections
Browse files Browse the repository at this point in the history
Add custom Sanity groq projections
  • Loading branch information
runeb committed Feb 13, 2021
2 parents 6b97e0f + d6e29e2 commit bf9f02b
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 50 deletions.
89 changes: 51 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ npm i sanity-algolia
Note that your serverless hosting might require a build step to properly deploy your serverless functions, and that the exported handler and passed parameters might differ from the following example, which is TypeScript on Vercel. Please refer to documentation on deploying functions at your hosting service of choice in order to adapt it to your own needs.

```typescript
import algoliasearch from "algoliasearch";
import sanityClient, { SanityDocumentStub } from "@sanity/client";
import { NowRequest, NowResponse } from "@vercel/node";
import indexer, { flattenBlocks } from "sanity-algolia";
import algoliasearch from 'algoliasearch'
import sanityClient, { SanityDocumentStub } from '@sanity/client'
import { NowRequest, NowResponse } from '@vercel/node'
import indexer, { flattenBlocks } from 'sanity-algolia'

const algolia = algoliasearch("application-id", "api-key");
const algolia = algoliasearch('application-id', 'api-key')
const sanity = sanityClient({
projectId: "my-sanity-project-id",
dataset: "my-dataset-name",
projectId: 'my-sanity-project-id',
dataset: 'my-dataset-name',
// If your dataset is private you need to add a read token.
// You can mint one at https://manage.sanity.io
token: "read-token",
token: 'read-token',
useCdn: false,
});
})

/**
* This function receives webhook POSTs from Sanity and updates, creates or
Expand All @@ -40,72 +40,84 @@ const handler = (req: NowRequest, res: NowResponse) => {
// Tip: Its good practice to include a shared secret in your webhook URLs and
// validate it before proceeding with webhook handling. Omitted in this short
// example.
if (req.headers["content-type"] !== "application/json") {
res.status(400);
res.json({ message: "Bad request" });
return;
if (req.headers['content-type'] !== 'application/json') {
res.status(400)
res.json({ message: 'Bad request' })
return
}

// Configure this to match an existing Algolia index name
const algoliaIndex = algolia.initIndex("my-index");
const algoliaIndex = algolia.initIndex('my-index')

const sanityAlgolia = indexer(
// A mapping of Sanity document _type names and their respective Algolia
// indices. In this example both document types live in the same index.
// The first parameter maps a Sanity document type to its respective Algolia
// search index. In this example both `post` and `article` Sanity types live
// in the same Algolia index. Optionally you can also customize how the
// document is fetched from Sanity by specifying a GROQ projection.
{
post: algoliaIndex,
article: algoliaIndex,
post: { index: algoliaIndex },
// For the article document in this example we want to resolve a list of
// references to authors. We can do this by customizing the projection for
// the article type. Here we fetch title, body and a resolved array of
// author documents.
article: {
index: algoliaIndex,
projection: '{title, body, authors[]->}',
},
},
// Serialization function. This is how you go from a Sanity document to an
// Algolia record. Notice the flattenBlocks method used for extracting the

// The second parameter is a function that maps from a fetched Sanity document
// to an Algolia Record. Notice the flattenBlocks method used for extracting the
// raw string values from portable text in this example.
(document: SanityDocumentStub) => {
switch (document._type) {
case "post":
case 'post':
return {
title: document.title,
path: document.slug.current,
body: flattenBlocks(document.body),
};
case "article":
}
case 'article':
return {
title: document.heading,
excerpt: flattenBlocks(document.excerpt),
body: flattenBlocks(document.body),
};
authorNames: document.authors.map((a) => a.name),
}
default:
throw new Error("You didnt handle a type you declared interest in");
throw new Error('You didnt handle a type you declared interest in')
}
},
// Visibility function (optional).
//
// Returning `true` for a given document here specifies that it should be
// indexed for search in Algolia. This is handy if for instance a field
// value on the document decides if it should be indexed or not. This would
// also be the place to implement any `publishedAt` datetime visibility
// rules or other custom scheme you may have set up.
// The third parameter is an optional visibility function. Returning `true`
// for a given document here specifies that it should be indexed for search
// in Algolia. This is handy if for instance a field value on the document
// decides if it should be indexed or not. This would also be the place to
// implement any `publishedAt` datetime visibility rules or other custom
// visibility scheme you may be using.
(document: SanityDocumentStub) => {
if (document.hasOwnProperty("isHidden")) {
return !document.isHidden;
if (document.hasOwnProperty('isHidden')) {
return !document.isHidden
}
return true;
return true
}
);
)

// Finally connect the Sanity webhook payload to Algolia indices via the
// configured serializers and optional visibility function. `webhookSync` will
// inspect the webhook payload, make queries back to Sanity with the `sanity`
// client and make sure the algolia indices are synced to match.
return sanityAlgolia
.webhookSync(sanity, req.body)
.then(() => res.status(200).send("ok"));
};
.then(() => res.status(200).send('ok'))
}

export default handler;
export default handler
```

## Todos

- [x] Easily customize projections for resolving references
- [ ] Use Algolia batch APIs?
- [ ] Example of initial indexing of existing content
- [ ] Handle situations where the record is too large to index.
Expand All @@ -114,5 +126,6 @@ export default handler;

- [Sanity webhook documentataion](https://www.sanity.io/docs/webhooks)
- [Algolia indexing documentation](https://www.algolia.com/doc/api-client/methods/indexing/)
- [The GROQ Query language](https://www.sanity.io/docs/groq)
- [Vercel Serverless Functions documentation](https://vercel.com/docs/serverless-functions/introduction)
- [Netlify functions documentation](https://docs.netlify.com/functions/build-with-javascript/)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.3.0",
"version": "1.0.0",
"license": "MIT",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
Expand Down
30 changes: 25 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,26 @@ import {

export { flattenBlocks } from './util'

type TypeConfig = {
index: SearchIndex
projection?: string
}

type IndexMap = {
[key: string]: SearchIndex
[key: string]: TypeConfig
}

export const indexMapProjection = (indexMap: IndexMap): string => {
const types = Object.keys(indexMap)
const res = `{
_id,
_type,
_rev,
${types
.map((t) => `_type == "${t}" => ${indexMap[t].projection || '{...}'}`)
.join(',\n ')}
}`
return res
}

const indexer = (
Expand Down Expand Up @@ -44,7 +62,9 @@ const indexer = (
//
// Fetch the full objects that we are probably going to index in Algolia. Some
// of these might get filtered out later by the optional visibility function.
const query = `* [(_id in $created + $updated) && _type in $types]`
const query = `* [(_id in $created + $updated) && _type in $types] ${indexMapProjection(
typeIndexMap
)}`
const { created, updated } = body.ids
const docs: SanityDocumentStub[] = await client.fetch(query, {
created,
Expand All @@ -71,7 +91,7 @@ const indexer = (

if (recordsToSave.length > 0) {
for (const type in typeIndexMap) {
await typeIndexMap[type].saveObjects(
await typeIndexMap[type].index.saveObjects(
recordsToSave.filter((r) => r.type === type)
)
}
Expand All @@ -87,8 +107,8 @@ const indexer = (
const recordsToDelete = deleted.concat(hiddenIds)

if (recordsToDelete.length > 0) {
for await (const typeIndex of Object.values(typeIndexMap)) {
typeIndex.deleteObjects(recordsToDelete)
for await (const typeIndexConfig of Object.values(typeIndexMap)) {
typeIndexConfig.index.deleteObjects(recordsToDelete)
}
}
}
Expand Down
51 changes: 45 additions & 6 deletions test/indexer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import indexer from '../src/index'
import indexer, { indexMapProjection } from '../src/index'
import fixture from './fixtures/internalFaq.json'
import { SearchIndex } from 'algoliasearch'

Expand All @@ -8,7 +8,7 @@ describe('transform', () => {
it('includes standard values for some standard properties', () => {
const algo = indexer(
{
internalFaq: mockIndex,
internalFaq: { index: mockIndex },
},
() => ({})
)
Expand All @@ -20,7 +20,7 @@ describe('transform', () => {
})

it('serialized according to passed function', () => {
const algo = indexer({ internalFaq: mockIndex }, (document) => {
const algo = indexer({ internalFaq: { index: mockIndex } }, (document) => {
return {
title: document.title,
body: 'flattened body',
Expand All @@ -39,7 +39,7 @@ describe('transform', () => {
})

it('can override default values', () => {
const algo = indexer({ internalFaq: mockIndex }, (_document) => {
const algo = indexer({ internalFaq: { index: mockIndex } }, (_document) => {
return {
objectId: 'totally custom',
type: 'invented',
Expand All @@ -56,6 +56,36 @@ describe('transform', () => {
})
})

describe('type index map', () => {
it('uses custom projection if specified for type', () => {
const postIndex = {
saveObjects: jest.fn(),
deleteObjects: jest.fn(),
}

const articleIndex = {
saveObjects: jest.fn(),
deleteObjects: jest.fn(),
}

const indexMap = {
post: { index: (postIndex as unknown) as SearchIndex },
article: {
index: (articleIndex as unknown) as SearchIndex,
projection: `{ authors[]-> }`,
},
}
const result = indexMapProjection(indexMap)
expect(result).toEqual(`{
_id,
_type,
_rev,
_type == "post" => {...},
_type == "article" => { authors[]-> }
}`)
})
})

describe('webhookSync', () => {
test.todo('uses the correct index')

Expand All @@ -72,8 +102,11 @@ describe('webhookSync', () => {

const i = indexer(
{
post: (postIndex as unknown) as SearchIndex,
article: (articleIndex as unknown) as SearchIndex,
post: { index: (postIndex as unknown) as SearchIndex },
article: {
index: (articleIndex as unknown) as SearchIndex,
projection: '{"title": "Hardcode"}',
},
},
() => ({
title: 'Hello',
Expand Down Expand Up @@ -119,6 +152,12 @@ describe('webhookSync', () => {
// Check that we queried for the updated and created objects of the types we
// are interested in
expect(client.fetch.mock.calls.length).toBe(1)
// Check no custom projection (... fetches all fields)
expect(client.fetch.mock.calls[0][0]).toContain('_type == "post" => {...}')
// Check the custom projection
expect(client.fetch.mock.calls[0][0]).toContain(
'_type == "article" => {"title": "Hardcode"}'
)
expect(client.fetch.mock.calls[0][1]).toMatchObject({
created: ['create-me', 'create-me-too'],
updated: ['update-me', 'ignore-me'],
Expand Down

0 comments on commit bf9f02b

Please sign in to comment.