Skip to content

Commit 7e6478f

Browse files
authored
Merge pull request #7 from thatapicompany/codex/add-bigquery-enrichment-for-places
feat: add optional BigQuery-backed enrichment for Places
2 parents 62e0981 + 4cce2f1 commit 7e6478f

File tree

14 files changed

+274
-12
lines changed

14 files changed

+274
-12
lines changed

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
GOOGLE_APPLICATION_CREDENTIALS=gcp-credentials.json
22
BIGQUERY_PROJECT_ID=your-GCP-project-id
33
GCS_BUCKET_NAME=your-GCS-bucket-name
4-
AUTH_API_ACCESS_KEY=create-one-from-TheAuthAPI.com
4+
AUTH_API_ACCESS_KEY=create-one-from-TheAuthAPI.com
5+
ENABLE_ENRICHMENT=false
6+
ENRICHMENT_ADAPTER_PACKAGE=
7+
ENRICHMENT_FIELDS_ALLOWLIST=
8+
ENRICHMENT_BQ_PROJECT=
9+
ENRICHMENT_BQ_DATASET=
10+
ENRICHMENT_BQ_TABLE=

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ The hosted version of the Overture Maps API has additional datasets including th
2222

2323
- [Deploy to Google Cloud Platform using Cloudrun and BigQuery](./docs/google-cloud-platform.md)
2424

25+
### Enrichment
26+
27+
Enrichment is disabled by default. Set `ENABLE_ENRICHMENT=true` in hosted deployments and provide the following environment variables to enable the built-in BigQuery-backed enrichment:
28+
29+
- `ENRICHMENT_BQ_PROJECT`
30+
- `ENRICHMENT_BQ_DATASET`
31+
- `ENRICHMENT_BQ_TABLE`
32+
- `ENRICHMENT_FIELDS_ALLOWLIST` (optional comma-separated field names)
33+
34+
Alternative adapters can be supplied via `ENRICHMENT_ADAPTER_PACKAGE`. This allows future data sources such as Postgres without changing the public API.
35+
2536
## API Endpoints
2637

2738
- [OpenAPI Spec Doc](https://api.overturemapsapi.com/api-docs.json)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "overture-maps-api",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "",
55
"author": "",
66
"private": true,

src/bigquery/bigquery.service.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ export class BigQueryService {
2727
logger = new Logger('BigQueryService');
2828

2929
constructor() {
30-
this.bigQueryClient = new BigQuery({
31-
projectId: process.env.BIGQUERY_PROJECT_ID,
32-
keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS,
33-
});
30+
31+
const config: any = {
32+
projectId: process.env.BIGQUERY_PROJECT_ID
33+
};
34+
35+
// Only add keyFilename if GOOGLE_APPLICATION_CREDENTIALS is set
36+
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
37+
config.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS;
38+
}
39+
40+
this.bigQueryClient = new BigQuery(config);
3441
}
3542

3643

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface EnrichmentAdapter {
2+
/**
3+
* Fetch enrichment data for a set of place IDs.
4+
* A future PgEnrichmentAdapter can be injected via ENRICHMENT_ADAPTER_PACKAGE without API changes.
5+
*/
6+
fetchEnrichmentByIds(
7+
ids: string[],
8+
options?: { fields?: string[] }
9+
): Promise<Record<string, Record<string, unknown>>>;
10+
11+
/**
12+
* List supported enrichment fields.
13+
*/
14+
supportedFields(): Promise<string[]>;
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { EnrichmentAdapter } from './EnrichmentAdapter';
2+
3+
export class OpenSourceEnrichmentAdapter implements EnrichmentAdapter {
4+
async fetchEnrichmentByIds(
5+
ids: string[],
6+
options?: { fields?: string[] }
7+
): Promise<Record<string, Record<string, unknown>>> {
8+
return {};
9+
}
10+
11+
async supportedFields(): Promise<string[]> {
12+
return [];
13+
}
14+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { BigQuery } from '@google-cloud/bigquery';
2+
import { EnrichmentAdapter } from '../EnrichmentAdapter';
3+
import { Logger } from '@nestjs/common';
4+
5+
function chunk<T>(arr: T[], size: number): T[][] {
6+
const chunks: T[][] = [];
7+
for (let i = 0; i < arr.length; i += size) {
8+
chunks.push(arr.slice(i, i + size));
9+
}
10+
return chunks;
11+
}
12+
13+
export class BqEnrichmentAdapter implements EnrichmentAdapter {
14+
private client: BigQuery;
15+
private project: string;
16+
private dataset: string;
17+
private table: string;
18+
private allowlist: string[];
19+
private cachedFields?: string[];
20+
21+
private logger: Logger;
22+
constructor() {
23+
24+
this.logger = new Logger('BqEnrichmentAdapter');
25+
26+
this.project = process.env.ENRICHMENT_BQ_PROJECT || '';
27+
this.dataset = process.env.ENRICHMENT_BQ_DATASET || '';
28+
this.table = process.env.ENRICHMENT_BQ_TABLE || '';
29+
this.allowlist = (process.env.ENRICHMENT_FIELDS_ALLOWLIST || '')
30+
.split(',')
31+
.map((s) => s.trim())
32+
.filter((s) => s.length > 0);
33+
this.logger.log(
34+
`Enrichment fields allowlist: ${this.allowlist.join(', ')}`
35+
);
36+
const config: any = {
37+
projectId: this.project,
38+
};
39+
40+
// Only add keyFilename if GOOGLE_APPLICATION_CREDENTIALS is set
41+
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
42+
config.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS;
43+
}
44+
45+
this.client = new BigQuery(config);
46+
}
47+
48+
async supportedFields(): Promise<string[]> {
49+
if (this.cachedFields) return this.cachedFields;
50+
const [metadata] = await this.client
51+
.dataset(this.dataset)
52+
.table(this.table)
53+
.getMetadata();
54+
let fields = (metadata.schema?.fields || [])
55+
.map((f: any) => f.name)
56+
.filter((name: string) => name !== 'id');
57+
if (this.allowlist.length) {
58+
fields = fields.filter((f) => this.allowlist.includes(f));
59+
}
60+
this.cachedFields = fields;
61+
return fields;
62+
}
63+
64+
async fetchEnrichmentByIds(
65+
ids: string[],
66+
options?: { fields?: string[] },
67+
): Promise<Record<string, Record<string, unknown>>> {
68+
if (!ids.length) return {};
69+
const allowed = await this.supportedFields();
70+
if (!allowed.length) {
71+
return {};
72+
}
73+
const requested = options?.fields?.length
74+
? options.fields.filter((f) => allowed.includes(f))
75+
: allowed;
76+
77+
const projection = requested.length ? requested.join(', ') : '';
78+
const results: Record<string, Record<string, unknown>> = {};
79+
for (const batch of chunk(ids, 5000)) {
80+
const query = `SELECT id${projection ? ', ' + projection : ''} FROM \`${this.project}.${this.dataset}.${this.table}\` WHERE id IN UNNEST(@ids)`;
81+
const options = { query, params: { ids: batch } } as any;
82+
const [rows] = await this.client.query(options);
83+
for (const row of rows as any[]) {
84+
const { id, ...rest } = row;
85+
results[id] = rest;
86+
}
87+
}
88+
return results;
89+
}
90+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { loadEnrichmentAdapter } from './loadEnrichmentAdapter';
2+
import { OpenSourceEnrichmentAdapter } from './OpenSourceEnrichmentAdapter';
3+
4+
describe('loadEnrichmentAdapter', () => {
5+
it('returns OpenSourceEnrichmentAdapter when disabled', () => {
6+
process.env.ENABLE_ENRICHMENT = 'false';
7+
const adapter = loadEnrichmentAdapter();
8+
expect(adapter).toBeInstanceOf(OpenSourceEnrichmentAdapter);
9+
});
10+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Logger } from '@nestjs/common';
2+
import { EnrichmentAdapter } from './EnrichmentAdapter';
3+
import { OpenSourceEnrichmentAdapter } from './OpenSourceEnrichmentAdapter';
4+
5+
// Loader that selects the enrichment adapter at runtime. A PgEnrichmentAdapter can
6+
// be plugged in via ENRICHMENT_ADAPTER_PACKAGE in the future.
7+
export function loadEnrichmentAdapter(): EnrichmentAdapter {
8+
const logger = new Logger('EnrichmentAdapterLoader');
9+
10+
try {
11+
if (process.env.ENABLE_ENRICHMENT !== 'true') {
12+
return new OpenSourceEnrichmentAdapter();
13+
}
14+
15+
if (process.env.ENRICHMENT_ADAPTER_PACKAGE) {
16+
const pkg = require(process.env.ENRICHMENT_ADAPTER_PACKAGE);
17+
const Adapter = pkg.default || pkg;
18+
return new Adapter();
19+
}
20+
21+
// Default to built-in BigQuery adapter
22+
const { BqEnrichmentAdapter } = require('./bq-enrichment/BqEnrichmentAdapter');
23+
return new BqEnrichmentAdapter();
24+
} catch (err: any) {
25+
logger.warn(`Failed to load enrichment adapter: ${err.message}`);
26+
return new OpenSourceEnrichmentAdapter();
27+
}
28+
}

src/gcs/gcs.service.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ export class GcsService {
1717
this.logger.error('GCS environment variables not set');
1818
return;
1919
}
20-
this.storage = new Storage({
21-
projectId: process.env.BIGQUERY_PROJECT_ID,
22-
keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS,
23-
});
20+
21+
const config: any = {
22+
projectId: process.env.BIGQUERY_PROJECT_ID
23+
};
24+
25+
// Only add keyFilename if GOOGLE_APPLICATION_CREDENTIALS is set
26+
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
27+
config.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS;
28+
}
29+
30+
this.storage = new Storage(config);
2431

2532
this.bucket = this.storage.bucket(process.env.GCS_BUCKET_NAME);
2633

0 commit comments

Comments
 (0)