Last active
February 29, 2024 23:13
-
-
Save gustavopch/ca4d15faa5ab2e76ba918b7adeba7fa2 to your computer and use it in GitHub Desktop.
Using Firestore via its REST API.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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