Skip to content

Instantly share code, notes, and snippets.

@gustavopch
Last active February 29, 2024 23:13
Show Gist options
  • Save gustavopch/ca4d15faa5ab2e76ba918b7adeba7fa2 to your computer and use it in GitHub Desktop.
Save gustavopch/ca4d15faa5ab2e76ba918b7adeba7fa2 to your computer and use it in GitHub Desktop.
Using Firestore via its REST API.
import { type CacheEntry, cachified } from '@epic-web/cachified'
import { distanceBetween, geohashQueryBounds } from 'geofire-common'
import * as jose from 'jose'
import { ofetch } from 'ofetch'
import { type Primitive } from 'type-fest'
import { env } from './env.js'
type UpdateData<T> = T extends Primitive
? T
: T extends NonNullable<unknown>
? { [K in keyof T]?: UpdateData<T[K]> } & NestedUpdateFields<T>
: Partial<T>
type NestedUpdateFields<T extends Record<string, unknown>> =
UnionToIntersection<
{
[K in keyof T & string]: ChildUpdateFields<K, T[K]>
}[keyof T & string]
>
type ChildUpdateFields<K extends string, V> =
V extends Record<string, unknown> ? AddPrefixToKeys<K, UpdateData<V>> : never
type AddPrefixToKeys<
Prefix extends string,
T extends Record<string, unknown>,
> = { [K in keyof T & string as `${Prefix}.${K}`]+?: T[K] }
type UnionToIntersection<U> = (
U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
const cache = new Map<string, CacheEntry>()
const getAccessTokenForFirestore = async () => {
return await cachified({
cache,
key: 'access-token',
ttl: 0, // Will be set below.
getFreshValue: async context => {
const serviceAccount = JSON.parse(
env('FIREBASE_SERVICE_ACCOUNT', {
unsafe: true,
}),
)
const privateKey = await jose.importPKCS8(
serviceAccount.private_key,
'RS256',
)
const expiresIn = 60 * 60
const signedJWT = await new jose.SignJWT({
scope:
'https://firestore.googleapis.com/google.firestore.v1beta1.Database',
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer(serviceAccount.client_email)
.setSubject(serviceAccount.client_email)
.setAudience('https://firestore.googleapis.com/')
.setExpirationTime(`${expiresIn}s`)
.sign(privateKey)
context.metadata.ttl = (expiresIn - 60) * 1000
return signedJWT
},
})
}
export const getDoc = async <TDocument extends { [key: string]: any }>(
path: string,
): Promise<TDocument | null> => {
const document = await ofetch(
`https://firestore.googleapis.com/v1/projects/${env('FIREBASE_PROJECT_ID')}/databases/(default)/documents/${path}`,
{
headers: {
authorization: `Bearer ${await getAccessTokenForFirestore()}`,
},
},
).catch(error => {
if (error.status === 404) {
return null
} else {
throw error
}
})
return document ? (decodeDocument(document) as TDocument) : null
}
export const runQuery = async <TDocument extends { [key: string]: any }>(
collection: string,
query: {
select?: string[]
where?: Array<
[
field: string,
op:
| '<'
| '<='
| '=='
| '>'
| '>='
| '!='
| 'array-contains'
| 'array-contains-any'
| 'in'
| 'not-in',
value: any,
]
>
orderBy?: Array<[field: string, direction: 'asc' | 'desc']>
startAt?: any[]
endAt?: any[]
offset?: number
limit?: number
},
): Promise<TDocument[]> => {
const results = await ofetch(
`https://firestore.googleapis.com/v1/projects/${env('FIREBASE_PROJECT_ID')}/databases/(default)/documents:runQuery`,
{
method: 'POST',
headers: {
authorization: `Bearer ${await getAccessTokenForFirestore()}`,
},
body: {
structuredQuery: {
select: query.select
? {
fields: query.select.map(field => ({ fieldPath: field })),
}
: undefined,
from: [{ collectionId: collection }],
where: query.where
? {
compositeFilter: {
op: 'AND',
filters: query.where.map(([field, op, value]) => {
if (op === '==' && value === null) {
return {
unaryFilter: {
field: { fieldPath: field },
op: 'IS_NULL',
},
}
}
if (op === '!=' && value === null) {
return {
unaryFilter: {
field: { fieldPath: field },
op: 'IS_NOT_NULL',
},
}
}
if (op === '==' && Number.isNaN(value)) {
return {
unaryFilter: {
field: { fieldPath: field },
op: 'IS_NAN',
},
}
}
if (op === '!=' && Number.isNaN(value)) {
return {
unaryFilter: {
field: { fieldPath: field },
op: 'IS_NOT_NAN',
},
}
}
return {
fieldFilter: {
field: { fieldPath: field },
op: {
'<': 'LESS_THAN',
'<=': 'LESS_THAN_OR_EQUAL',
'==': 'EQUAL',
'>': 'GREATER_THAN',
'>=': 'GREATER_THAN_OR_EQUAL',
'!=': 'NOT_EQUAL',
'array-contains': 'ARRAY_CONTAINS',
'array-contains-any': 'ARRAY_CONTAINS_ANY',
in: 'IN',
'not-in': 'NOT_IN',
}[op],
value: encodeValue(value),
},
}
}),
},
}
: undefined,
orderBy: query.orderBy?.map(([field, direction]) => ({
field: { fieldPath: field },
direction: {
asc: 'ASCENDING',
desc: 'DESCENDING',
}[direction],
})),
startAt: query.startAt
? { values: query.startAt.map(value => encodeValue(value)) }
: undefined,
endAt: query.endAt
? { values: query.endAt.map(value => encodeValue(value)) }
: undefined,
offset: query.offset,
limit: query.limit,
},
},
},
)
if (!results[0].document) {
results.splice(0, 1)
}
return results.map(
(result: any) => decodeDocument(result.document) as TDocument,
)
}
export const runGeoQuery = async <TDocument extends Record<string, any>>(
collection: string,
{
geohashField,
getCoordinates,
center,
radiusInMeters,
}: {
geohashField: string
getCoordinates: (doc: TDocument) => { latitude: number; longitude: number }
center: { latitude: number; longitude: number }
radiusInMeters: number
},
): Promise<TDocument[]> => {
const bounds = geohashQueryBounds(
[center.latitude, center.longitude],
radiusInMeters,
)
const withinGeohashBounds = await Promise.all(
bounds.map(([start, end]) =>
runQuery<TDocument>(collection, {
orderBy: [[geohashField, 'asc']],
startAt: [start],
endAt: [end],
}),
),
).then(docs => docs.flat())
const withinRadius = withinGeohashBounds.filter(doc => {
const { latitude, longitude } = getCoordinates(doc)
const distanceInMeters =
distanceBetween(
[latitude, longitude],
[center.latitude, center.longitude],
) * 1000
return distanceInMeters < radiusInMeters
})
return withinRadius
}
export const addDoc = async <TDocument extends { [key: string]: any }>(
collection: string,
data: Omit<TDocument, 'id'>,
): Promise<void> => {
await ofetch(
`https://firestore.googleapis.com/v1/projects/${env('FIREBASE_PROJECT_ID')}/databases/(default)/documents/${collection}`,
{
method: 'POST',
query: {
'mask.fieldPaths': ['__name__'],
},
headers: {
authorization: `Bearer ${await getAccessTokenForFirestore()}`,
},
body: encodeDocument({
fields: data,
}),
},
)
}
export const setDoc = async <TDocument extends { [key: string]: any }>(
path: string,
data: Omit<TDocument, 'id'>,
): Promise<void> => {
await ofetch(
`https://firestore.googleapis.com/v1/projects/${env('FIREBASE_PROJECT_ID')}/databases/(default)/documents/${path}`,
{
method: 'PATCH',
query: {
'mask.fieldPaths': ['__name__'],
},
headers: {
authorization: `Bearer ${await getAccessTokenForFirestore()}`,
},
body: encodeDocument({
fields: data,
}),
},
)
}
export const updateDoc = async <TDocument extends { [key: string]: any }>(
path: string,
data: UpdateData<Omit<TDocument, 'id'>>,
): Promise<void> => {
const fieldPaths = Object.keys(data).filter(key => data[key] !== undefined)
if (fieldPaths.length === 0) {
return
}
await ofetch(
`https://firestore.googleapis.com/v1/projects/${env('FIREBASE_PROJECT_ID')}/databases/(default)/documents/${path}`,
{
method: 'PATCH',
query: {
'updateMask.fieldPaths': fieldPaths,
'mask.fieldPaths': ['__name__'],
'currentDocument.exists': true,
},
headers: {
authorization: `Bearer ${await getAccessTokenForFirestore()}`,
},
body: encodeDocument({
fields: data,
interpretDotNotation: true,
}),
},
)
}
export const deleteDoc = async (path: string): Promise<void> => {
await ofetch(
`https://firestore.googleapis.com/v1/projects/${env('FIREBASE_PROJECT_ID')}/databases/(default)/documents/${path}`,
{
method: 'DELETE',
headers: {
authorization: `Bearer ${await getAccessTokenForFirestore()}`,
},
},
)
}
const encodeDocument = ({
interpretDotNotation,
...document
}: {
interpretDotNotation?: boolean
fields: { [key: string]: any }
}) => {
const { fields } = encodeValue(document.fields).mapValue
for (const [key, value] of Object.entries(document.fields)) {
if (interpretDotNotation && key.includes('.')) {
const keys = key.split('.')
let current = fields
for (const key of keys) {
if (keys.indexOf(key) === keys.length - 1) {
current[key] = encodeValue(value)
} else {
current[key] ??= encodeValue({})
current = current[key].mapValue.fields
}
}
} else {
fields[key] = encodeValue(value)
}
}
return {
fields,
}
}
const decodeDocument = (document: {
name: string
fields: { [key: string]: any }
}) => {
return {
id: document.name.split('/').slice(-1)[0],
...decodeValue({ mapValue: { fields: document.fields } }),
}
}
const encodeValue = (value: any): any => {
if (value === undefined || value instanceof DeleteField) {
return undefined
}
if (value === null) {
return { nullValue: null }
}
if (typeof value === 'boolean') {
return { booleanValue: value }
}
if (typeof value === 'number') {
return Number.isInteger(value)
? { integerValue: value }
: { doubleValue: value }
}
if (typeof value === 'string') {
return { stringValue: value }
}
if (value instanceof Date) {
return { timestampValue: value.toISOString() }
}
if (value instanceof GeoPoint) {
return { geoPointValue: value.toJSON() }
}
if (Array.isArray(value)) {
const values = value.map(encodeValue).filter(Boolean)
return { arrayValue: { values } }
}
if (typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, value]) => {
const encodedValue = encodeValue(value)
return encodedValue ? [key, encodedValue] : undefined
})
.filter(Boolean)
return { mapValue: { fields: Object.fromEntries(entries) } }
}
throw new Error('Failed to encode value')
}
const decodeValue = (value: any): any => {
if ('nullValue' in value) {
return null
}
if ('booleanValue' in value) {
return value.booleanValue
}
if ('integerValue' in value) {
return Number(value.integerValue)
}
if ('doubleValue' in value) {
return value.doubleValue
}
if ('stringValue' in value) {
return value.stringValue
}
if ('timestampValue' in value) {
return new Date(value.timestampValue)
}
if ('geoPointValue' in value) {
return new GeoPoint(
value.geoPointValue.latitude,
value.geoPointValue.longitude,
)
}
if ('arrayValue' in value) {
return value.arrayValue.values?.map(decodeValue) ?? []
}
if ('mapValue' in value) {
const entries = Object.entries(value.mapValue.fields ?? {}).map(
([key, value]) => [key, decodeValue(value)],
)
return Object.fromEntries(entries)
}
throw new Error(`Failed to decode value: ${JSON.stringify(value, null, 2)}`)
}
export const downloadURLToPath = (url: string): string => {
return decodeURIComponent(new URL(url).pathname.split('/').slice(-1)[0])
}
export const pathToDownloadURL = (path: string): string => {
return `https://firebasestorage.googleapis.com/v0/b/${env(
'FIREBASE_PROJECT_ID',
)}.appspot.com/o/${encodeURIComponent(path)}?alt=media`
}
export class DeleteField {}
export class GeoPoint {
// eslint-disable-next-line no-useless-constructor
constructor(
public latitude: number,
public longitude: number,
) {}
isEqual(other: GeoPoint) {
return (
this.latitude === other.latitude && this.longitude === other.longitude
)
}
toJSON() {
return {
latitude: this.latitude,
longitude: this.longitude,
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment