Skip to content

Commit 904b2c3

Browse files
authored
Add TypeSense to Firestore FTS (#883)
1 parent 6ce74a7 commit 904b2c3

File tree

5 files changed

+190
-27
lines changed

5 files changed

+190
-27
lines changed

fulltext-search-firestore/README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
# Full Text search via Algolia
1+
# Full Text search
22

3-
This template shows how to enable full text search on Firestore documents by using an [Algolia](https://algolia.com) hosted search service.
3+
This template shows how to enable full text search on Firestore documents by using one of the followning hosted search services:
4+
5+
* [Algolia](https://algolia.com)
6+
* [Elastic](https://elastic.co)
7+
* [Typesense](https://typesense.org)
48

59
## Functions Code
610

@@ -16,17 +20,14 @@ As an example we'll be using a secure note structure:
1620
/notes
1721
/note-123456
1822
text: "This is my first note...",
19-
author: "FIREBASE_USER_ID"
23+
owner: "FIREBASE_USER_ID"
2024
/note-123457
2125
text: "This is my second note entry...",
22-
author: "FIREBASE_USER_ID"
26+
owner: "FIREBASE_USER_ID"
2327
tags: ["some_category"]
2428
```
2529

26-
Whenever a new note is created or modified a Function sends the content to be indexed to the Algolia instance.
27-
28-
To securely search notes, a user is issued a [Secured API Key](https://www.algolia.com/doc/guides/security/api-keys/#secured-api-keys) from Algolia which
29-
limits which documents they can search through.
30+
Whenever a new note is created or modified a Function sends the content to be indexed.
3031

3132
## Setting up the sample
3233

fulltext-search-firestore/functions/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ app.get('/', (req, res) => {
101101
// Create the params object as described in the Algolia documentation:
102102
// https://www.algolia.com/doc/guides/security/api-keys/#generating-api-keys
103103
const params = {
104-
// This filter ensures that only documents where author == uid will be readable
105-
filters: `author:${uid}`,
104+
// This filter ensures that only documents where owner == uid will be readable
105+
filters: `owner:${uid}`,
106106
// We also proxy the uid as a unique token for this key.
107107
userToken: uid,
108108
};

fulltext-search-firestore/functions/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"cors": "^2.8.5",
88
"express": "^4.17.1",
99
"firebase-admin": "^9.9.0",
10-
"firebase-functions": "^3.14.1"
10+
"firebase-functions": "^3.14.1",
11+
"typesense": "^0.13.0"
1112
},
1213
"devDependencies": {
1314
"eslint": "^6.8.0",
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Copyright 2021 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the 'License');
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an 'AS IS' BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
const functions = require("firebase-functions");
17+
18+
// [START init_typesense]
19+
// Initialize Typesense, requires installing Typesense dependencies:
20+
// https://github.com/typesense/typesense-js
21+
const Typesense = require("typesense");
22+
23+
// Typesense API keys are stored in functions config variables
24+
const TYPESENSE_ADMIN_API_KEY = functions.config().typesense.admin_api_key;
25+
const TYPESENSE_SEARCH_API_KEY = functions.config().typesense.search_api_key;
26+
27+
const client = new Typesense.Client({
28+
'nodes': [{
29+
'host': 'xxx.a1.typesense.net', // where xxx is the ClusterID of your Typesense Cloud cluster
30+
'port': '443',
31+
'protocol': 'https'
32+
}],
33+
'apiKey': TYPESENSE_ADMIN_API_KEY,
34+
'connectionTimeoutSeconds': 2
35+
});
36+
// [END init_typesense]
37+
38+
// [START create_typesense_collections]
39+
async function createTypesenseCollections() {
40+
// Every 'collection' in Typesense needs a schema. A collection only
41+
// needs to be created one time before you index your first document.
42+
//
43+
// Alternatively, use auto schema detection:
44+
// https://typesense.org/docs/latest/api/collections.html#with-auto-schema-detection
45+
const notesCollection = {
46+
'name': 'notes',
47+
'fields': [
48+
{'name': 'id', 'type': 'string'},
49+
{'name': 'owner', 'type': 'string' },
50+
{'name': 'text', 'type': 'string' }
51+
]
52+
};
53+
54+
await client.collections().create(notesCollection);
55+
}
56+
// [END create_typesense_collections]
57+
58+
// [START update_index_function_typesense]
59+
// Update the search index every time a blog post is written.
60+
exports.onNoteWritten = functions.firestore.document('notes/{noteId}').onWrite(async (snap, context) => {
61+
// Use the 'nodeId' path segment as the identifier for Typesense
62+
const id = context.params.noteId;
63+
64+
// If the note is deleted, delete the note from the Typesense index
65+
if (!snap.after.exists) {
66+
await client.collections('notes').documents(id).delete();
67+
return;
68+
}
69+
70+
// Otherwise, create/update the note in the the Typesense index
71+
const note = snap.after.data();
72+
await client.collections('notes').documents().upsert({
73+
id,
74+
owner: note.owner,
75+
text: note.text
76+
});
77+
});
78+
// [END update_index_function_typesense]
79+
80+
// [START api_key_function_typesense]
81+
exports.getScopedApiKey = functions.https.onCall(async (data, context) => {
82+
// Ensure that the user is authenticated with Firebase Auth
83+
if (!(context.auth && context.auth.uid)) {
84+
throw new functions.https.HttpsError('permission-denied', 'Must be signed in!');
85+
}
86+
87+
// Generate a scoped API key which allows the user to search ONLY
88+
// documents which belong to them (based on the 'owner' field).
89+
const scopedApiKey = client.keys().generateScopedSearchKey(
90+
TYPESENSE_SEARCH_API_KEY,
91+
{
92+
'filter_by': `owner:${context.auth.uid}`
93+
}
94+
);
95+
96+
return {
97+
key: scopedApiKey
98+
};
99+
});
100+
// [END api_key_function_typesense]

fulltext-search-firestore/public/index.js

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@
1414
* limitations under the License.
1515
*/
1616

17-
var PROJECT_ID = '' // Required - your Firebase project ID
18-
var ALGOLIA_APP_ID = ''; // Required - your Algolia app ID
19-
var ALGOLIA_SEARCH_KEY = ''; // Optional - Only used for unauthenticated search
17+
const PROJECT_ID = '...' // Required - your Firebase project ID
2018

21-
function unauthenticated_search(query) {
19+
const ALGOLIA_APP_ID = '...'; // Required - your Algolia app ID
20+
const ALGOLIA_SEARCH_KEY = '...'; // Optional - Only used for unauthenticated search
21+
22+
// A search-only API Typesense API key. NEVER use your Admin API Key in a
23+
// web app. You can generate API keys using either the TypeSense Cloud console
24+
// or the TypeSense API.
25+
const TYPESENSE_SEARCH_API_KEY = '...';
26+
27+
function searchAlgoliaUnauthenticated(query) {
2228

2329
// [START search_index_unsecure]
2430
var client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_KEY);
@@ -38,7 +44,7 @@ function unauthenticated_search(query) {
3844
// [END search_index_unsecure]
3945
}
4046

41-
function authenticated_search(query) {
47+
function searchAlgoliaAuthenticated(query) {
4248
var client;
4349
var index;
4450
// [START search_index_secure]
@@ -76,11 +82,11 @@ function search(query) {
7682
console.warn('Please set ALGOLIA_APP_ID in /index.js!');
7783
} else if (ALGOLIA_SEARCH_KEY) {
7884
console.log('Performing unauthenticated search...');
79-
return unauthenticated_search(query);
85+
return searchAlgoliaUnauthenticated(query);
8086
} else {
8187
return firebase.auth().signInAnonymously()
8288
.then(function() {
83-
return authenticated_search(query).catch(function(err) {
89+
return searchAlgoliaAuthenticated(query).catch(function(err) {
8490
console.warn(err);
8591
});
8692
}).catch(function(err) {
@@ -91,21 +97,76 @@ function search(query) {
9197
}
9298

9399
function searchElastic(query) {
94-
// [START search_elastic]
95-
const searchNotes = firebase.functions().httpsCallable('searchNotes');
96-
searchNotes({ query: query })
97-
.then((result) => {
98-
const notes = result.data.notes;
99-
// ...
100-
});
101-
// [END search_elastic]
100+
// [START search_elastic]
101+
const searchNotes = firebase.functions().httpsCallable('searchNotes');
102+
searchNotes({ query: query })
103+
.then((result) => {
104+
const notes = result.data.notes;
105+
// ...
106+
});
107+
// [END search_elastic]
108+
}
109+
110+
async function searchTypesenseUnauthenticated(query) {
111+
// [START search_typesense_unauthenticated]
112+
// Create a Typesense Client using the search-only API key
113+
const client = new Typesense.Client({
114+
'nodes': [{
115+
'host': 'xxx.a1.typesense.net', // where xxx is the ClusterID of your Typesense Cloud cluster
116+
'port': '443',
117+
'protocol': 'https'
118+
}],
119+
'apiKey': TYPESENSE_SEARCH_API_KEY,
120+
'connectionTimeoutSeconds': 2
121+
});
122+
123+
// Search for notes with matching text
124+
const searchParameters = {
125+
'q': query,
126+
'query_by': 'text'
127+
};
128+
const searchResults = await client.collections('notes')
129+
.documents()
130+
.search(searchParameters);
131+
// ...
132+
// [END search_typesense_unauthenticated]
133+
}
134+
135+
async function searchTypesenseAuthenticated(query) {
136+
// [START search_typesense_authenticated]
137+
// Get a scoped TypeSense API key from our Callable Function
138+
const getScopedApiKey = firebase.functions().httpsCallable('getScopedApiKey');
139+
const scopedApiKeyResponse = await getScopedApiKey();
140+
const apiKey = scopedApiKeyResponse.data.key;
141+
142+
// Create a Typesense Client
143+
const client = new Typesense.Client({
144+
'nodes': [{
145+
'host': 'xxx.a1.typesense.net', // where xxx is the ClusterID of your Typesense Cloud cluster
146+
'port': '443',
147+
'protocol': 'https'
148+
}],
149+
'apiKey': apiKey,
150+
'connectionTimeoutSeconds': 2
151+
});
152+
153+
// Search for notes with matching text
154+
const searchParameters = {
155+
'q': query,
156+
'query_by': 'text'
157+
};
158+
const searchResults = await client.collections('notes')
159+
.documents()
160+
.search(searchParameters);
161+
// ...
162+
// [END search_typesense_authenticated]
102163
}
103164

104165
// Other code to wire up the buttons and textboxes.
105166

106167
document.querySelector('#do-add-note').addEventListener('click', function() {
107168
firebase.firestore().collection('notes').add({
108-
author: [firebase.auth().currentUser.uid],
169+
owner: [firebase.auth().currentUser.uid],
109170
text: document.querySelector('#note-text').value
110171
}).then(function() {
111172
document.querySelector('#note-text').value = '';

0 commit comments

Comments
 (0)