diff --git a/src/lib/maps/Client.ts b/src/lib/maps/Client.ts new file mode 100644 index 00000000..63e11ec4 --- /dev/null +++ b/src/lib/maps/Client.ts @@ -0,0 +1,265 @@ +import { Credentials } from '../core/Credentials'; +import errorHandlers from './errors'; +import { encodeParameter, getRequest, postRequest } from './utils'; + +const REQUEST_GET_MAX_URL_LENGTH = 2048; +const VECTOR_EXTENT = 2048; +const VECTOR_SIMPLIFY_EXTENT = 2048; + +export class Client { + private _credentials: Credentials; + + constructor(credentials: Credentials) { + this._credentials = credentials; + } + + /** + * Instantiate a map based on dataset name or a sql query, returning a layergroup + * + * @param options + */ + public async instantiateMapFrom(options: MapOptions) { + const { + sql, + dataset, + vectorExtent = VECTOR_EXTENT, + vectorSimplifyExtent = VECTOR_SIMPLIFY_EXTENT, + metadata = {}, + aggregation = {}, + bufferSize + } = options; + + if (!(sql || dataset)) { + throw new Error('Please provide a dataset or a SQL query'); + } + + const mapConfig = { + version: '1.3.1', + buffersize: bufferSize, + layers: [ + { + type: 'mapnik', + options: { + sql: sql || `select * from ${dataset}`, + vector_extent: vectorExtent, + vector_simplify_extent: vectorSimplifyExtent, + metadata, + aggregation + } + } + ] + }; + + return this.instantiateMap(mapConfig); + } + + public static generateMapConfigFromSource(source: string) { + const type = source.search(' ') > -1 ? 'sql' : 'dataset'; + + return { + [type]: source, + vectorExtent: VECTOR_EXTENT, + vectorSimplifyExtent: VECTOR_SIMPLIFY_EXTENT, + analyses: [ + { + type: 'source', + id: `${source}_${Date.now()}`, + params: { + query: `SELECT * FROM ${source}` + } + } + ], + layers: [] + }; + } + + /** + * + * @param layergroup + * @param options + */ + public async aggregationDataview(layergroup: any, dataview: string, categories?: number) { + const { + metadata: { + dataviews: { + [dataview]: { url } + } + } + } = layergroup; + + const parameters = [encodeParameter('api_key', this._credentials.apiKey)]; + + if (categories) { + const encodedCategories = encodeParameter('categories', categories.toString()); + parameters.push(encodedCategories); + } + + const getUrl = `${url.https}?${parameters.join('&')}`; + const response = await fetch(getRequest(getUrl)); + const dataviewResponse = await response.json(); + + return dataviewResponse; + } + + public async instantiateMap(mapConfig: unknown) { + let response; + + try { + const payload = JSON.stringify(mapConfig); + response = await fetch(this.makeMapsApiRequest(payload)); + } catch (error) { + throw new Error( + `Failed to connect to Maps API with the user ('${this._credentials.username}'): ${error}` + ); + } + + const layergroup = (await response.json()) as never; + + if (!response.ok) { + this.dealWithWindshaftErrors(response, layergroup); + } + + return layergroup; + } + + private makeMapsApiRequest(config: string) { + const encodedApiKey = encodeParameter('api_key', this._credentials.apiKey); + const parameters = [encodedApiKey]; + const url = this.generateMapsApiUrl(parameters); + + const getUrl = `${url}&${encodeParameter('config', config)}`; + + if (getUrl.length < REQUEST_GET_MAX_URL_LENGTH) { + return getRequest(getUrl); + } + + return postRequest(url, config); + } + + private dealWithWindshaftErrors( + response: { status: number }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layergroup: any + ) { + const errorForCode = errorHandlers[response.status]; + + if (errorForCode) { + errorForCode(this._credentials); + return; + } + + throw new Error(`${JSON.stringify(layergroup.errors)}`); + } + + private generateMapsApiUrl(parameters: string[] = []) { + const base = `${this._credentials.serverURL}api/v1/map`; + return `${base}?${parameters.join('&')}`; + } +} + +export interface AggregationColumn { + // eslint-disable-next-line camelcase + aggregate_function: string; + // eslint-disable-next-line camelcase + aggregated_column: string; +} + +export interface StatsColumn { + topCategories: number; + includeNulls: boolean; +} + +export interface Sample { + // eslint-disable-next-line camelcase + num_rows: number; + // eslint-disable-next-line camelcase + include_columns: string[]; +} + +export interface MapOptions { + bufferSize?: BufferSizeOptions; + sql?: string; + dataset?: string; + vectorExtent: number; + vectorSimplifyExtent: number; + metadata?: { + geometryType: boolean; + columnStats?: StatsColumn; + dimensions?: boolean; + sample?: Sample; + }; + aggregation?: { + placement: string; + resolution: number; + threshold?: number; + columns?: Record; + dimensions?: Record; + }; +} + +interface BufferSizeOptions { + png?: number; + 'grid.json'?: number; + mvt?: number; +} + +export interface MapInstance { + layergroupid: string; + // eslint-disable-next-line camelcase + last_updated: string; + metadata: { + layers: [ + { + type: string; + id: string; + meta: { + stats: { + estimatedFeatureCount: number; + geometryType: string; + // TODO: create a proper type for columns + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: any; + sample: number[]; + }; + aggregation: { + png: boolean; + mvt: boolean; + }; + }; + tilejson: { + vector: { + tilejson: string; + tiles: string[]; + }; + }; + } + ]; + tilejson: { + vector: { + tilejson: string; + tiles: string[]; + }; + }; + url: { + vector: { + urlTemplate: string; + subdomains: string[]; + }; + }; + // eslint-disable-next-line camelcase + cdn_url: { + http: string; + https: string; + templates: { + http: { + subdomains: string[]; + url: string; + }; + https: { + subdomains: string[]; + url: string; + }; + }; + }; + }; +} diff --git a/src/lib/maps/MapsDataviews.ts b/src/lib/maps/MapsDataviews.ts new file mode 100644 index 00000000..82eb3611 --- /dev/null +++ b/src/lib/maps/MapsDataviews.ts @@ -0,0 +1,113 @@ +import { Credentials } from '../core/Credentials'; +import { Client } from './Client'; + +export class MapsDataviews { + private _source: string; + private _mapClient: Client; + + constructor(source: string, credentials: Credentials) { + this._source = source; + this._mapClient = new Client(credentials); + } + + public async aggregation(params: AggregationParameters): Promise { + const { column, aggregation, operationColumn, limit } = params; + + const dataviewName = `${this._source}_${Date.now()}`; + const layergroup = await this._createMapWithAggregationDataviews( + dataviewName, + column, + aggregation, + operationColumn + ); + + const aggregationResponse = this._mapClient.aggregationDataview( + layergroup, + dataviewName, + limit + ); + + return (aggregationResponse as unknown) as AggregationResponse; + } + + private _createMapWithAggregationDataviews( + name: string, + column: string, + aggregation: AggregationType, + operationColumn?: string + ) { + const sourceMapConfig = Client.generateMapConfigFromSource(this._source); + const sourceId = sourceMapConfig.analyses[0].id; + const mapConfig = { + ...sourceMapConfig, + dataviews: { + [name]: { + type: 'aggregation', + source: { id: sourceId }, + options: { + column, + aggregation, + aggregationColumn: operationColumn + } + } + } + }; + + const response = this._mapClient.instantiateMap(mapConfig); + return response; + } +} + +export interface AggregationResponse { + count: number; + max: number; + min: number; + nulls: number; + nans: number; + infinities: number; + aggregation: AggregationType; + categoriesCount: number; + categories: AggregationCategory[]; + // eslint-disable-next-line camelcase + errors_with_context?: { type: string; message: string }[]; + errors?: string[]; +} + +export interface AggregationCategory { + agg: boolean; + category: string; + value: number; +} + +export interface AggregationParameters { + /** + * column name to aggregate by + */ + column: string; + + /** + * operation to perform + */ + aggregation: AggregationType; + + /** + * The num of categories + */ + limit?: number; + + /** + * Column value to aggregate. + * This param is required when + * `aggregation` is different than "count" + */ + operationColumn?: string; +} + +export enum AggregationType { + COUNT = 'count', + AVG = 'avg', + MIN = 'min', + MAX = 'max', + SUM = 'sum', + PERCENTILE = 'percentile' +} diff --git a/src/lib/maps/__tests__/Client.spec.ts b/src/lib/maps/__tests__/Client.spec.ts new file mode 100644 index 00000000..b6a6d610 --- /dev/null +++ b/src/lib/maps/__tests__/Client.spec.ts @@ -0,0 +1,26 @@ +import { Credentials } from '../../core/Credentials'; +import { MapOptions, Client } from '../Client'; + +describe('maps', () => { + it('can be easily created', () => { + const credentials = new Credentials('aUser', 'anApiKey'); + const m = new Client(credentials); + expect(m).toBeTruthy(); + }); + + describe('create a simple map', () => { + it('fails without dataset or sql query', async () => { + const credentials = new Credentials('aUser', 'anApiKey'); + const m = new Client(credentials); + + const mapOptions: MapOptions = { + vectorExtent: 2048, + vectorSimplifyExtent: 2048 + }; + + await expect(m.instantiateMapFrom(mapOptions)).rejects.toThrowError( + 'Please provide a dataset or a SQL query' + ); + }); + }); +}); diff --git a/src/lib/maps/errors.ts b/src/lib/maps/errors.ts new file mode 100644 index 00000000..50fd04bd --- /dev/null +++ b/src/lib/maps/errors.ts @@ -0,0 +1,18 @@ +import { Credentials } from '../core/Credentials'; + +const unauthorized = (credentials: Credentials) => { + throw new Error( + `Unauthorized access to Maps API: invalid combination of user ('${credentials.username}') and apiKey ('${credentials.apiKey}')` + ); +}; + +const unauthorizedDataset = (credentials: Credentials) => { + throw new Error( + `Unauthorized access to dataset: the provided apiKey('${credentials.apiKey}') doesn't provide access to the requested data` + ); +}; + +export default { + [401 as number]: unauthorized, + [403 as number]: unauthorizedDataset +}; diff --git a/src/lib/maps/utils.ts b/src/lib/maps/utils.ts new file mode 100644 index 00000000..a361f228 --- /dev/null +++ b/src/lib/maps/utils.ts @@ -0,0 +1,23 @@ +export function getRequest(url: string) { + return new Request(url, { + method: 'GET', + headers: { + Accept: 'application/json' + } + }); +} + +export function postRequest(url: string, payload: string) { + return new Request(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: payload + }); +} + +export function encodeParameter(name: string, value: string) { + return `${name}=${encodeURIComponent(value)}`; +}