diff --git a/package.json b/package.json index 61033c0..8e2f3be 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "repository": "github:CartoDB/carto-api-client", "author": "Don McCurdy ", "packageManager": "yarn@4.3.1", - "version": "0.3.1", + "version": "0.4.0-alpha.5", "license": "MIT", "publishConfig": { "access": "public", @@ -52,7 +52,6 @@ "LICENSE.md" ], "dependencies": { - "@deck.gl/carto": "^9.0.30", "@turf/bbox-clip": "^7.1.0", "@turf/bbox-polygon": "^7.1.0", "@turf/helpers": "^7.1.0", @@ -62,6 +61,7 @@ }, "devDependencies": { "@deck.gl/aggregation-layers": "^9.0.30", + "@deck.gl/carto": "^9.0.30", "@deck.gl/core": "^9.0.30", "@deck.gl/extensions": "^9.0.30", "@deck.gl/geo-layers": "^9.0.30", @@ -98,5 +98,6 @@ "vite": "^5.2.10", "vitest": "1.6.0", "vue": "^3.4.27" - } + }, + "stableVersion": "0.3.1" } diff --git a/src/api/carto-api-error.ts b/src/api/carto-api-error.ts new file mode 100644 index 0000000..7257538 --- /dev/null +++ b/src/api/carto-api-error.ts @@ -0,0 +1,88 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {MapType} from '../types'; + +export type APIRequestType = + | 'Map data' + | 'Map instantiation' + | 'Public map' + | 'Tile stats' + | 'SQL' + | 'Basemap style'; + +export type APIErrorContext = { + requestType: APIRequestType; + mapId?: string; + connection?: string; + source?: string; + type?: MapType; +}; + +/** + * + * Custom error for reported errors in CARTO Maps API. + * Provides useful debugging information in console and context for applications. + * + */ +export class CartoAPIError extends Error { + /** Source error from server */ + error: Error; + + /** Context (API call & parameters) in which error occured */ + errorContext: APIErrorContext; + + /** Response from server */ + response?: Response; + + /** JSON Response from server */ + responseJson?: any; + + constructor( + error: Error, + errorContext: APIErrorContext, + response?: Response, + responseJson?: any + ) { + let responseString = 'Failed to connect'; + if (response) { + responseString = 'Server returned: '; + if (response.status === 400) { + responseString += 'Bad request'; + } else if (response.status === 401 || response.status === 403) { + responseString += 'Unauthorized access'; + } else if (response.status === 404) { + responseString += 'Not found'; + } else { + responseString += 'Error'; + } + + responseString += ` (${response.status}):`; + } + responseString += ` ${error.message || error}`; + + let message = `${errorContext.requestType} API request failed`; + message += `\n${responseString}`; + for (const key of Object.keys(errorContext)) { + if (key === 'requestType') continue; + message += `\n${formatErrorKey(key)}: ${(errorContext as any)[key]}`; + } + message += '\n'; + + super(message); + + this.name = 'CartoAPIError'; + this.response = response; + this.responseJson = responseJson; + this.error = error; + this.errorContext = errorContext; + } +} + +/** + * Converts camelCase to Camel Case + */ +function formatErrorKey(key: string) { + return key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()); +} diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000..e36a178 --- /dev/null +++ b/src/api/endpoints.ts @@ -0,0 +1,84 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {MapType} from '../types.js'; + +export type V3Endpoint = 'maps' | 'stats' | 'sql'; + +function joinPath(...args: string[]): string { + return args + .map((part) => (part.endsWith('/') ? part.slice(0, -1) : part)) + .join('/'); +} + +function buildV3Path( + apiBaseUrl: string, + version: 'v3', + endpoint: V3Endpoint, + ...rest: string[] +): string { + return joinPath(apiBaseUrl, version, endpoint, ...rest); +} + +/** @internal Required by fetchMap(). */ +export function buildPublicMapUrl({ + apiBaseUrl, + cartoMapId, +}: { + apiBaseUrl: string; + cartoMapId: string; +}): string { + return buildV3Path(apiBaseUrl, 'v3', 'maps', 'public', cartoMapId); +} + +/** @internal Required by fetchMap(). */ +export function buildStatsUrl({ + attribute, + apiBaseUrl, + connectionName, + source, + type, +}: { + attribute: string; + apiBaseUrl: string; + connectionName: string; + source: string; + type: MapType; +}): string { + if (type === 'query') { + return buildV3Path(apiBaseUrl, 'v3', 'stats', connectionName, attribute); + } + + // type === 'table' + return buildV3Path( + apiBaseUrl, + 'v3', + 'stats', + connectionName, + source, + attribute + ); +} + +export function buildSourceUrl({ + apiBaseUrl, + connectionName, + endpoint, +}: { + apiBaseUrl: string; + connectionName: string; + endpoint: MapType; +}): string { + return buildV3Path(apiBaseUrl, 'v3', 'maps', connectionName, endpoint); +} + +export function buildQueryUrl({ + apiBaseUrl, + connectionName, +}: { + apiBaseUrl: string; + connectionName: string; +}): string { + return buildV3Path(apiBaseUrl, 'v3', 'sql', connectionName, 'query'); +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..4531ee2 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,14 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export { + CartoAPIError, + APIErrorContext, + APIRequestType, +} from './carto-api-error.js'; +// Internal, but required for fetchMap(). +export {buildPublicMapUrl, buildStatsUrl} from './endpoints.js'; +export {query} from './query.js'; +export type {QueryOptions} from './query.js'; +export {requestWithParameters} from './request-with-parameters.js'; diff --git a/src/api/query.ts b/src/api/query.ts new file mode 100644 index 0000000..3678038 --- /dev/null +++ b/src/api/query.ts @@ -0,0 +1,56 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {SOURCE_DEFAULTS} from '../sources/index'; +import type { + SourceOptions, + QuerySourceOptions, + QueryResult, +} from '../sources/types'; +import {buildQueryUrl} from './endpoints'; +import {requestWithParameters} from './request-with-parameters'; +import {APIErrorContext} from './carto-api-error'; + +export type QueryOptions = SourceOptions & + Omit; +type UrlParameters = {q: string; queryParameters?: string}; + +export const query = async function ( + options: QueryOptions +): Promise { + const { + apiBaseUrl = SOURCE_DEFAULTS.apiBaseUrl, + clientId = SOURCE_DEFAULTS.clientId, + maxLengthURL = SOURCE_DEFAULTS.maxLengthURL, + connectionName, + sqlQuery, + queryParameters, + } = options; + const urlParameters: UrlParameters = {q: sqlQuery}; + + if (queryParameters) { + urlParameters.queryParameters = JSON.stringify(queryParameters); + } + + const baseUrl = buildQueryUrl({apiBaseUrl, connectionName}); + const headers = { + Authorization: `Bearer ${options.accessToken}`, + ...options.headers, + }; + const parameters = {client: clientId, ...urlParameters}; + + const errorContext: APIErrorContext = { + requestType: 'SQL', + connection: options.connectionName, + type: 'query', + source: JSON.stringify(parameters, undefined, 2), + }; + return await requestWithParameters({ + baseUrl, + parameters, + headers, + errorContext, + maxLengthURL, + }); +}; diff --git a/src/api/request-with-parameters.ts b/src/api/request-with-parameters.ts new file mode 100644 index 0000000..0cffe1a --- /dev/null +++ b/src/api/request-with-parameters.ts @@ -0,0 +1,136 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {isPureObject} from '../utils'; +import {CartoAPIError, APIErrorContext} from './carto-api-error'; +import {V3_MINOR_VERSION} from '../constants-internal'; +import {DEFAULT_MAX_LENGTH_URL} from '../constants-internal'; +import {getClient} from '../client'; + +const DEFAULT_HEADERS = { + Accept: 'application/json', + 'Content-Type': 'application/json', +}; + +const REQUEST_CACHE = new Map>(); + +export async function requestWithParameters({ + baseUrl, + parameters = {}, + headers: customHeaders = {}, + errorContext, + maxLengthURL = DEFAULT_MAX_LENGTH_URL, +}: { + baseUrl: string; + parameters?: Record; + headers?: Record; + errorContext: APIErrorContext; + maxLengthURL?: number; +}): Promise { + // Parameters added to all requests issued with `requestWithParameters()`. + // These parameters override parameters already in the base URL, but not + // user-provided parameters. + parameters = { + v: V3_MINOR_VERSION, + client: getClient(), + ...(typeof deck !== 'undefined' && + deck.VERSION && {deckglVersion: deck.VERSION}), + ...parameters, + }; + + baseUrl = excludeURLParameters(baseUrl, Object.keys(parameters)); + const key = createCacheKey(baseUrl, parameters, customHeaders); + if (REQUEST_CACHE.has(key)) { + return REQUEST_CACHE.get(key) as Promise; + } + + const url = createURLWithParameters(baseUrl, parameters); + const headers = {...DEFAULT_HEADERS, ...customHeaders}; + + /* global fetch */ + const fetchPromise = + url.length > maxLengthURL + ? fetch(baseUrl, { + method: 'POST', + body: JSON.stringify(parameters), + headers, + }) + : fetch(url, {headers}); + + let response: Response | undefined; + let responseJson: unknown; + const jsonPromise: Promise = fetchPromise + .then((_response: Response) => { + response = _response; + return response.json(); + }) + .then((json: any) => { + responseJson = json; + if (!response || !response.ok) { + throw new Error(json.error); + } + return json; + }) + .catch((error: Error) => { + REQUEST_CACHE.delete(key); + throw new CartoAPIError(error, errorContext, response, responseJson); + }); + + REQUEST_CACHE.set(key, jsonPromise); + return jsonPromise; +} + +function createCacheKey( + baseUrl: string, + parameters: Record, + headers: Record +): string { + const parameterEntries = Object.entries(parameters).sort(([a], [b]) => + a > b ? 1 : -1 + ); + const headerEntries = Object.entries(headers).sort(([a], [b]) => + a > b ? 1 : -1 + ); + return JSON.stringify({ + baseUrl, + parameters: parameterEntries, + headers: headerEntries, + }); +} + +/** + * Appends query string parameters to a URL. Existing URL parameters are kept, + * unless there is a conflict, in which case the new parameters override + * those already in the URL. + */ +function createURLWithParameters( + baseUrlString: string, + parameters: Record +): string { + const baseUrl = new URL(baseUrlString); + for (const [key, value] of Object.entries(parameters)) { + if (isPureObject(value) || Array.isArray(value)) { + baseUrl.searchParams.set(key, JSON.stringify(value)); + } else { + baseUrl.searchParams.set( + key, + (value as string | boolean | number).toString() + ); + } + } + return baseUrl.toString(); +} + +/** + * Deletes query string parameters from a URL. + */ +function excludeURLParameters(baseUrlString: string, parameters: string[]) { + const baseUrl = new URL(baseUrlString); + for (const param of parameters) { + if (baseUrl.searchParams.has(param)) { + baseUrl.searchParams.delete(param); + } + } + return baseUrl.toString(); +} diff --git a/src/client.ts b/src/client.ts index eefe1c8..8e4d502 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,8 +1,8 @@ /** * @internal - * @internalRemarks Source: @carto/react-core + * @internalRemarks Source: @carto/react-core, @carto/constants, @deck.gl/carto */ -let client = 'carto-api-client'; +let client = 'deck-gl-carto'; /** * Returns current client ID, used to categorize API requests. For internal use only. diff --git a/src/constants-internal.ts b/src/constants-internal.ts index 8e1b99e..811dd34 100644 --- a/src/constants-internal.ts +++ b/src/constants-internal.ts @@ -1,45 +1,32 @@ -/****************************************************************************** - * DEFAULTS - */ - /** - * @internalRemarks Source: @carto/constants + * Current version of @carto/api-client. * @internal */ -export const DEFAULT_API_BASE_URL = 'https://gcp-us-east1.api.carto.com'; +export const API_CLIENT_VERSION = __CARTO_API_CLIENT_VERSION; -/** - * @internalRemarks Source: @carto/constants - * @internal - */ -export const DEFAULT_CLIENT = 'deck-gl-carto'; +/** @internal */ +export const V3_MINOR_VERSION = '3.4'; -/** - * @internalRemarks Source: @carto/react-api - * @internal - */ +/** @internalRemarks Source: @carto/constants, @deck.gl/carto */ export const DEFAULT_GEO_COLUMN = 'geom'; -/****************************************************************************** - * ENUMS +/** + * Fastly default limit is 8192; leave some padding. + * @internalRemarks Source: @deck.gl/carto */ +export const DEFAULT_MAX_LENGTH_URL = 7000; + +/** @internalRemarks Source: @deck.gl/carto */ +export const DEFAULT_TILE_RESOLUTION = 0.5; /** + * @internalRemarks Source: @deck.gl/carto * @internal - * @internalRemarks Source: @carto/constants */ -export enum MapType { - TABLE = 'table', - QUERY = 'query', - TILESET = 'tileset', -} +export const DEFAULT_AGGREGATION_RES_LEVEL_H3 = 4; /** + * @internalRemarks Source: @deck.gl/carto * @internal - * @internalRemarks Source: @carto/constants */ -export enum ApiVersion { - V1 = 'v1', - V2 = 'v2', - V3 = 'v3', -} +export const DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN = 6; diff --git a/src/constants.ts b/src/constants.ts index a64d55c..a67256d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,3 @@ -/** Current version of @carto/api-client. */ -export const API_CLIENT_VERSION = __CARTO_API_CLIENT_VERSION; - /** * Defines a comparator used when matching a column's values against given filter values. * @@ -24,3 +21,13 @@ export enum FilterType { TIME = 'time', STRING_SEARCH = 'stringSearch', } + +/** @internalRemarks Source: @carto/constants */ +export enum ApiVersion { + V1 = 'v1', + V2 = 'v2', + V3 = 'v3', +} + +/** @internalRemarks Source: @carto/constants, @deck.gl/carto */ +export const DEFAULT_API_BASE_URL = 'https://gcp-us-east1.api.carto.com'; diff --git a/src/global.d.ts b/src/global.d.ts index 3d985d1..f523574 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -5,3 +5,6 @@ * ``` */ declare const __CARTO_API_CLIENT_VERSION: string; + +/** Defined by @deck.gl/core. */ +declare const deck: {VERSION: string | undefined} | undefined; diff --git a/src/index.ts b/src/index.ts index 62db2e6..dda6dec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,52 @@ export * from './client.js'; export * from './constants.js'; export * from './filters.js'; export * from './geo.js'; -export * from './sources/index.js'; +export * from './widget-sources/index.js'; export * from './types.js'; + +export { + APIErrorContext, + APIRequestType, + CartoAPIError, + QueryOptions, + buildPublicMapUrl, // Internal, but required for fetchMap(). + buildStatsUrl, // Internal, but required for fetchMap(). + query, + requestWithParameters, +} from './api/index.js'; + +export { + BoundaryQuerySourceOptions, + BoundaryTableSourceOptions, + GeojsonResult, + H3QuerySourceOptions, + H3TableSourceOptions, + H3TilesetSourceOptions, + JsonResult, + QuadbinQuerySourceOptions, + QuadbinTableSourceOptions, + QuadbinTilesetSourceOptions, + QueryResult, + QuerySourceOptions, + RasterSourceOptions, + SOURCE_DEFAULTS, + SourceOptions, + TableSourceOptions, + TilejsonResult, + TilesetSourceOptions, + VectorQuerySourceOptions, + VectorTableSourceOptions, + VectorTilesetSourceOptions, + boundaryQuerySource, + boundaryTableSource, + h3QuerySource, + h3TableSource, + h3TilesetSource, + quadbinQuerySource, + quadbinTableSource, + quadbinTilesetSource, + rasterSource, + vectorQuerySource, + vectorTableSource, + vectorTilesetSource, +} from './sources/index.js'; diff --git a/src/models/common.ts b/src/models/common.ts index ed5b9c6..b108924 100644 --- a/src/models/common.ts +++ b/src/models/common.ts @@ -1,4 +1,3 @@ -import {$TODO} from '../types-internal.js'; import {InvalidColumnError} from '../utils.js'; /** @internalRemarks Source: @carto/react-api */ @@ -9,6 +8,12 @@ export interface ModelRequestOptions { body?: string; } +interface ModelErrorResponse { + error?: string | string[]; + hint?: string; + column_name?: string; +} + /** * Return more descriptive error from API * @internalRemarks Source: @carto/react-api @@ -18,13 +23,16 @@ export function dealWithApiError({ data, }: { response: Response; - data: $TODO; + data: ModelErrorResponse; }) { if (data.error === 'Column not found') { throw new InvalidColumnError(`${data.error} ${data.column_name}`); } - if (data.error?.includes('Missing columns')) { + if ( + typeof data.error === 'string' && + data.error?.includes('Missing columns') + ) { throw new InvalidColumnError(data.error); } diff --git a/src/models/model.ts b/src/models/model.ts index ab0c00e..08c2edd 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -1,17 +1,15 @@ -import { - ApiVersion, - DEFAULT_GEO_COLUMN, - MapType, -} from '../constants-internal.js'; +import {DEFAULT_GEO_COLUMN} from '../constants-internal.js'; import { Filter, FilterLogicalOperator, + MapType, QueryParameters, SpatialFilter, } from '../types.js'; import {$TODO} from '../types-internal.js'; import {assert} from '../utils.js'; import {ModelRequestOptions, makeCall} from './common.js'; +import {ApiVersion} from '../constants.js'; /** @internalRemarks Source: @carto/react-api */ const AVAILABLE_MODELS = [ @@ -72,7 +70,7 @@ export function executeModel(props: { assert(apiBaseUrl, 'executeModel: missing apiBaseUrl'); assert(accessToken, 'executeModel: missing accessToken'); assert(apiVersion === V3, 'executeModel: SQL Model API requires CARTO 3+'); - assert(type !== MapType.TILESET, 'executeModel: Tilesets not supported'); + assert(type !== 'tileset', 'executeModel: Tilesets not supported'); let url = `${apiBaseUrl}/v3/sql/${connectionName}/model/${model}`; diff --git a/src/sources/base-source.ts b/src/sources/base-source.ts new file mode 100644 index 0000000..3b2f530 --- /dev/null +++ b/src/sources/base-source.ts @@ -0,0 +1,98 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {DEFAULT_API_BASE_URL} from '../constants'; +import {DEFAULT_MAX_LENGTH_URL} from '../constants-internal'; +import {buildSourceUrl} from '../api/endpoints'; +import {requestWithParameters} from '../api/request-with-parameters'; +import type { + GeojsonResult, + JsonResult, + SourceOptionalOptions, + SourceRequiredOptions, + TilejsonMapInstantiation, + TilejsonResult, +} from './types'; +import {MapType} from '../types'; +import {APIErrorContext} from '../api'; +import {getClient} from '../client'; + +export const SOURCE_DEFAULTS: SourceOptionalOptions = { + apiBaseUrl: DEFAULT_API_BASE_URL, + clientId: getClient(), + format: 'tilejson', + headers: {}, + maxLengthURL: DEFAULT_MAX_LENGTH_URL, +}; + +export async function baseSource>( + endpoint: MapType, + options: Partial & SourceRequiredOptions, + urlParameters: UrlParameters +): Promise { + const {accessToken, connectionName, cache, ...optionalOptions} = options; + const mergedOptions = { + ...SOURCE_DEFAULTS, + accessToken, + connectionName, + endpoint, + }; + for (const key in optionalOptions) { + if (optionalOptions[key as keyof typeof optionalOptions]) { + (mergedOptions as any)[key] = + optionalOptions[key as keyof typeof optionalOptions]; + } + } + const baseUrl = buildSourceUrl(mergedOptions); + const {clientId, maxLengthURL, format} = mergedOptions; + const headers = { + Authorization: `Bearer ${options.accessToken}`, + ...options.headers, + }; + const parameters = {client: clientId, ...urlParameters}; + + const errorContext: APIErrorContext = { + requestType: 'Map instantiation', + connection: options.connectionName, + type: endpoint, + source: JSON.stringify(parameters, undefined, 2), + }; + const mapInstantiation = + await requestWithParameters({ + baseUrl, + parameters, + headers, + errorContext, + maxLengthURL, + }); + + const dataUrl = mapInstantiation[format].url[0]; + if (cache) { + cache.value = parseInt( + new URL(dataUrl).searchParams.get('cache') || '', + 10 + ); + } + errorContext.requestType = 'Map data'; + + if (format === 'tilejson') { + const json = await requestWithParameters({ + baseUrl: dataUrl, + headers, + errorContext, + maxLengthURL, + }); + if (accessToken) { + json.accessToken = accessToken; + } + return json; + } + + return await requestWithParameters({ + baseUrl: dataUrl, + headers, + errorContext, + maxLengthURL, + }); +} diff --git a/src/sources/boundary-query-source.ts b/src/sources/boundary-query-source.ts new file mode 100644 index 0000000..de2d7bf --- /dev/null +++ b/src/sources/boundary-query-source.ts @@ -0,0 +1,53 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {QueryParameters} from '../types.js'; +import {baseSource} from './base-source'; +import type {FilterOptions, SourceOptions, TilejsonResult} from './types'; + +export type BoundaryQuerySourceOptions = SourceOptions & + FilterOptions & { + columns?: string[]; + tilesetTableName: string; + propertiesSqlQuery: string; + queryParameters?: QueryParameters; + }; +type UrlParameters = { + columns?: string; + filters?: Record; + tilesetTableName: string; + propertiesSqlQuery: string; + queryParameters?: Record | unknown[]; +}; + +export const boundaryQuerySource = async function ( + options: BoundaryQuerySourceOptions +): Promise { + const { + columns, + filters, + tilesetTableName, + propertiesSqlQuery, + queryParameters, + } = options; + const urlParameters: UrlParameters = { + tilesetTableName, + propertiesSqlQuery, + }; + + if (columns) { + urlParameters.columns = columns.join(','); + } + if (filters) { + urlParameters.filters = filters; + } + if (queryParameters) { + urlParameters.queryParameters = queryParameters; + } + return baseSource( + 'boundary', + options, + urlParameters + ) as Promise; +}; diff --git a/src/sources/boundary-table-source.ts b/src/sources/boundary-table-source.ts new file mode 100644 index 0000000..05d3dae --- /dev/null +++ b/src/sources/boundary-table-source.ts @@ -0,0 +1,41 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {baseSource} from './base-source'; +import type {FilterOptions, SourceOptions, TilejsonResult} from './types'; + +export type BoundaryTableSourceOptions = SourceOptions & + FilterOptions & { + tilesetTableName: string; + columns?: string[]; + propertiesTableName: string; + }; +type UrlParameters = { + filters?: Record; + tilesetTableName: string; + columns?: string; + propertiesTableName: string; +}; + +export const boundaryTableSource = async function ( + options: BoundaryTableSourceOptions +): Promise { + const {filters, tilesetTableName, columns, propertiesTableName} = options; + const urlParameters: UrlParameters = { + tilesetTableName, + propertiesTableName, + }; + + if (columns) { + urlParameters.columns = columns.join(','); + } + if (filters) { + urlParameters.filters = filters; + } + return baseSource( + 'boundary', + options, + urlParameters + ) as Promise; +}; diff --git a/src/sources/h3-query-source.ts b/src/sources/h3-query-source.ts new file mode 100644 index 0000000..230b357 --- /dev/null +++ b/src/sources/h3-query-source.ts @@ -0,0 +1,65 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable camelcase */ +import {DEFAULT_AGGREGATION_RES_LEVEL_H3} from '../constants-internal'; +import {WidgetQuerySource, WidgetQuerySourceResult} from '../widget-sources'; +import {baseSource} from './base-source'; +import type { + AggregationOptions, + FilterOptions, + QuerySourceOptions, + SourceOptions, + SpatialDataType, + TilejsonResult, +} from './types'; + +export type H3QuerySourceOptions = SourceOptions & + QuerySourceOptions & + AggregationOptions & + FilterOptions; +type UrlParameters = { + aggregationExp: string; + aggregationResLevel?: string; + spatialDataType: SpatialDataType; + spatialDataColumn?: string; + q: string; + queryParameters?: Record | unknown[]; + filters?: Record; +}; + +export const h3QuerySource = async function ( + options: H3QuerySourceOptions +): Promise { + const { + aggregationExp, + aggregationResLevel = DEFAULT_AGGREGATION_RES_LEVEL_H3, + sqlQuery, + spatialDataColumn = 'h3', + queryParameters, + filters, + } = options; + const urlParameters: UrlParameters = { + aggregationExp, + spatialDataColumn, + spatialDataType: 'h3', + q: sqlQuery, + }; + + if (aggregationResLevel) { + urlParameters.aggregationResLevel = String(aggregationResLevel); + } + if (queryParameters) { + urlParameters.queryParameters = queryParameters; + } + if (filters) { + urlParameters.filters = filters; + } + return baseSource('query', options, urlParameters).then( + (result) => ({ + ...(result as TilejsonResult), + widgetSource: new WidgetQuerySource(options), + }) + ); +}; diff --git a/src/sources/h3-table-source.ts b/src/sources/h3-table-source.ts new file mode 100644 index 0000000..6bad8a9 --- /dev/null +++ b/src/sources/h3-table-source.ts @@ -0,0 +1,61 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable camelcase */ +import {DEFAULT_AGGREGATION_RES_LEVEL_H3} from '../constants-internal'; +import {WidgetTableSource, WidgetTableSourceResult} from '../widget-sources'; +import {baseSource} from './base-source'; +import type { + AggregationOptions, + FilterOptions, + SourceOptions, + SpatialDataType, + TableSourceOptions, + TilejsonResult, +} from './types'; + +export type H3TableSourceOptions = SourceOptions & + TableSourceOptions & + AggregationOptions & + FilterOptions; + +type UrlParameters = { + aggregationExp: string; + aggregationResLevel?: string; + spatialDataType: SpatialDataType; + spatialDataColumn?: string; + name: string; + filters?: Record; +}; + +export const h3TableSource = async function ( + options: H3TableSourceOptions +): Promise { + const { + aggregationExp, + aggregationResLevel = DEFAULT_AGGREGATION_RES_LEVEL_H3, + spatialDataColumn = 'h3', + tableName, + filters, + } = options; + const urlParameters: UrlParameters = { + aggregationExp, + name: tableName, + spatialDataColumn, + spatialDataType: 'h3', + }; + + if (aggregationResLevel) { + urlParameters.aggregationResLevel = String(aggregationResLevel); + } + if (filters) { + urlParameters.filters = filters; + } + return baseSource('table', options, urlParameters).then( + (result) => ({ + ...(result as TilejsonResult), + widgetSource: new WidgetTableSource(options), + }) + ); +}; diff --git a/src/sources/h3-tileset-source.ts b/src/sources/h3-tileset-source.ts new file mode 100644 index 0000000..ac1752c --- /dev/null +++ b/src/sources/h3-tileset-source.ts @@ -0,0 +1,26 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {baseSource} from './base-source'; +import type { + SourceOptions, + TilejsonResult, + TilesetSourceOptions, +} from './types'; + +export type H3TilesetSourceOptions = SourceOptions & TilesetSourceOptions; +type UrlParameters = {name: string}; + +export const h3TilesetSource = async function ( + options: H3TilesetSourceOptions +): Promise { + const {tableName} = options; + const urlParameters: UrlParameters = {name: tableName}; + + return baseSource( + 'tileset', + options, + urlParameters + ) as Promise; +}; diff --git a/src/sources/index.ts b/src/sources/index.ts index 7bf51ee..4be769f 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -1,5 +1,54 @@ -export * from './widget-base-source.js'; -export * from './widget-query-source.js'; -export * from './widget-table-source.js'; -export * from './wrappers.js'; -export * from './types.js'; +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export {SOURCE_DEFAULTS} from './base-source'; +export type { + TilejsonResult, + GeojsonResult, + JsonResult, + QueryResult, +} from './types'; + +export {boundaryQuerySource} from './boundary-query-source'; +export type {BoundaryQuerySourceOptions} from './boundary-query-source'; + +export {boundaryTableSource} from './boundary-table-source'; +export type {BoundaryTableSourceOptions} from './boundary-table-source'; + +export {h3QuerySource} from './h3-query-source'; +export type {H3QuerySourceOptions} from './h3-query-source'; + +export {h3TableSource} from './h3-table-source'; +export type {H3TableSourceOptions} from './h3-table-source'; + +export {h3TilesetSource} from './h3-tileset-source'; +export type {H3TilesetSourceOptions} from './h3-tileset-source'; + +export {rasterSource} from './raster-source'; +export type {RasterSourceOptions} from './raster-source'; + +export {quadbinQuerySource} from './quadbin-query-source'; +export type {QuadbinQuerySourceOptions} from './quadbin-query-source'; + +export {quadbinTableSource} from './quadbin-table-source'; +export type {QuadbinTableSourceOptions} from './quadbin-table-source'; + +export {quadbinTilesetSource} from './quadbin-tileset-source'; +export type {QuadbinTilesetSourceOptions} from './quadbin-tileset-source'; + +export {vectorQuerySource} from './vector-query-source'; +export type {VectorQuerySourceOptions} from './vector-query-source'; + +export {vectorTableSource} from './vector-table-source'; +export type {VectorTableSourceOptions} from './vector-table-source'; + +export {vectorTilesetSource} from './vector-tileset-source'; +export type {VectorTilesetSourceOptions} from './vector-tileset-source'; + +export type { + SourceOptions, + QuerySourceOptions, + TableSourceOptions, + TilesetSourceOptions, +} from './types'; diff --git a/src/sources/quadbin-query-source.ts b/src/sources/quadbin-query-source.ts new file mode 100644 index 0000000..0e6ea7f --- /dev/null +++ b/src/sources/quadbin-query-source.ts @@ -0,0 +1,66 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable camelcase */ +import {DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN} from '../constants-internal'; +import {WidgetQuerySource, WidgetQuerySourceResult} from '../widget-sources'; +import {baseSource} from './base-source'; +import type { + AggregationOptions, + FilterOptions, + QuerySourceOptions, + SourceOptions, + SpatialDataType, + TilejsonResult, +} from './types'; + +export type QuadbinQuerySourceOptions = SourceOptions & + QuerySourceOptions & + AggregationOptions & + FilterOptions; + +type UrlParameters = { + aggregationExp: string; + aggregationResLevel?: string; + spatialDataType: SpatialDataType; + spatialDataColumn?: string; + q: string; + queryParameters?: Record | unknown[]; + filters?: Record; +}; + +export const quadbinQuerySource = async function ( + options: QuadbinQuerySourceOptions +): Promise { + const { + aggregationExp, + aggregationResLevel = DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN, + sqlQuery, + spatialDataColumn = 'quadbin', + queryParameters, + filters, + } = options; + const urlParameters: UrlParameters = { + aggregationExp, + q: sqlQuery, + spatialDataColumn, + spatialDataType: 'quadbin', + }; + + if (aggregationResLevel) { + urlParameters.aggregationResLevel = String(aggregationResLevel); + } + if (queryParameters) { + urlParameters.queryParameters = queryParameters; + } + if (filters) { + urlParameters.filters = filters; + } + return baseSource('query', options, urlParameters).then( + (result) => ({ + ...(result as TilejsonResult), + widgetSource: new WidgetQuerySource(options), + }) + ); +}; diff --git a/src/sources/quadbin-table-source.ts b/src/sources/quadbin-table-source.ts new file mode 100644 index 0000000..91b7a1c --- /dev/null +++ b/src/sources/quadbin-table-source.ts @@ -0,0 +1,62 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable camelcase */ +import {DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN} from '../constants-internal'; +import {WidgetTableSource, WidgetTableSourceResult} from '../widget-sources'; +import {baseSource} from './base-source'; +import type { + AggregationOptions, + FilterOptions, + SourceOptions, + SpatialDataType, + TableSourceOptions, + TilejsonResult, +} from './types'; + +export type QuadbinTableSourceOptions = SourceOptions & + TableSourceOptions & + AggregationOptions & + FilterOptions; + +type UrlParameters = { + aggregationExp: string; + aggregationResLevel?: string; + spatialDataType: SpatialDataType; + spatialDataColumn?: string; + name: string; + filters?: Record; +}; + +export const quadbinTableSource = async function ( + options: QuadbinTableSourceOptions +): Promise { + const { + aggregationExp, + aggregationResLevel = DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN, + spatialDataColumn = 'quadbin', + tableName, + filters, + } = options; + + const urlParameters: UrlParameters = { + aggregationExp, + name: tableName, + spatialDataColumn, + spatialDataType: 'quadbin', + }; + + if (aggregationResLevel) { + urlParameters.aggregationResLevel = String(aggregationResLevel); + } + if (filters) { + urlParameters.filters = filters; + } + return baseSource('table', options, urlParameters).then( + (result) => ({ + ...(result as TilejsonResult), + widgetSource: new WidgetTableSource(options), + }) + ); +}; diff --git a/src/sources/quadbin-tileset-source.ts b/src/sources/quadbin-tileset-source.ts new file mode 100644 index 0000000..db188df --- /dev/null +++ b/src/sources/quadbin-tileset-source.ts @@ -0,0 +1,26 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {baseSource} from './base-source'; +import type { + SourceOptions, + TilejsonResult, + TilesetSourceOptions, +} from './types'; + +export type QuadbinTilesetSourceOptions = SourceOptions & TilesetSourceOptions; +type UrlParameters = {name: string}; + +export const quadbinTilesetSource = async function ( + options: QuadbinTilesetSourceOptions +): Promise { + const {tableName} = options; + const urlParameters: UrlParameters = {name: tableName}; + + return baseSource( + 'tileset', + options, + urlParameters + ) as Promise; +}; diff --git a/src/sources/raster-source.ts b/src/sources/raster-source.ts new file mode 100644 index 0000000..565ead4 --- /dev/null +++ b/src/sources/raster-source.ts @@ -0,0 +1,34 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {baseSource} from './base-source'; +import type { + FilterOptions, + SourceOptions, + TilejsonResult, + TilesetSourceOptions, +} from './types'; + +export type RasterSourceOptions = SourceOptions & + TilesetSourceOptions & + FilterOptions; +type UrlParameters = { + name: string; + filters?: Record; +}; + +export const rasterSource = async function ( + options: RasterSourceOptions +): Promise { + const {tableName, filters} = options; + const urlParameters: UrlParameters = {name: tableName}; + if (filters) { + urlParameters.filters = filters; + } + return baseSource( + 'raster', + options, + urlParameters + ) as Promise; +}; diff --git a/src/sources/types.ts b/src/sources/types.ts index 05b035c..b039d18 100644 --- a/src/sources/types.ts +++ b/src/sources/types.ts @@ -1,105 +1,237 @@ -import { - GroupDateType, - SortColumnType, - SortDirection, - SpatialFilter, -} from '../types'; - -/****************************************************************************** - * WIDGET API REQUESTS - */ - -/** Common options for {@link WidgetBaseSource} requests. */ -interface BaseRequestOptions { - spatialFilter?: SpatialFilter; - abortController?: AbortController; - filterOwner?: string; -} +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors -/** Options for {@link WidgetBaseSource#getCategories}. */ -export interface CategoryRequestOptions extends BaseRequestOptions { - column: string; - operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; - operationColumn?: string; -} +import type {Feature} from 'geojson'; +import {Filters, Format, QueryParameters} from '../types'; +import {MapInstantiation} from '../types-internal'; -/** Options for {@link WidgetBaseSource#getFormula}. */ -export interface FormulaRequestOptions extends BaseRequestOptions { - column: string; - operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; - operationExp?: string; -} +export type SourceRequiredOptions = { + /** Carto platform access token. */ + accessToken: string; -/** Options for {@link WidgetBaseSource#getHistogram}. */ -export interface HistogramRequestOptions extends BaseRequestOptions { - column: string; - ticks: number[]; - operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; -} + /** Data warehouse connection name in Carto platform. */ + connectionName: string; +}; -/** Options for {@link WidgetBaseSource#getRange}. */ -export interface RangeRequestOptions extends BaseRequestOptions { - column: string; -} +export type SourceOptionalOptions = { + /** + * Base URL of the CARTO Maps API. + * + * Example for account located in EU-west region: `https://gcp-eu-west1.api.carto.com` + * + * @default https://gcp-us-east1.api.carto.com + */ + apiBaseUrl: string; + + /** + * Custom HTTP headers added to map instantiation and data requests. + */ + headers: Record; + + /** + * Cache buster value returned by map instantiation. + * + * Carto source saves `cache` value of map instantiation response in `cache.value`, so it can be used to + * check if underlying map data has changed between distinct source requests. + */ + cache?: {value?: number}; + + clientId: string; + /** @deprecated use `query` instead **/ + format: Format; + + /** + * Maximum URL character length. Above this limit, requests use POST. + * Used to avoid browser and CDN limits. + * @default {@link DEFAULT_MAX_LENGTH_URL} + */ + maxLengthURL?: number; +}; -/** Options for {@link WidgetBaseSource#getScatter}. */ -export interface ScatterRequestOptions extends BaseRequestOptions { - xAxisColumn: string; - xAxisJoinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; - yAxisColumn: string; - yAxisJoinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; -} +export type SourceOptions = SourceRequiredOptions & + Partial; + +export type AggregationOptions = { + /** + * Defines the aggregation expressions that will be calculated from the resulting columns on each grid cell. + * + * Example: + * + * sum(pop) as total_population, avg(rev) as average_revenue + */ + aggregationExp: string; + + /** + * Defines the tile aggregation resolution. + * + * @default 6 for quadbin and 4 for h3 sources + */ + aggregationResLevel?: number; +}; -/** Options for {@link WidgetBaseSource#getTable}. */ -export interface TableRequestOptions extends BaseRequestOptions { - columns: string[]; - sortBy?: string; - sortDirection?: SortDirection; - sortByColumnType?: SortColumnType; - offset?: number; - limit?: number; -} +export type FilterOptions = { + /** + * Filters to apply to the data source on the server + */ + filters?: Filters; +}; -/** Options for {@link WidgetBaseSource#getTimeSeries}. */ -export interface TimeSeriesRequestOptions extends BaseRequestOptions { - column: string; - stepSize?: GroupDateType; - stepMultiplier?: number; - operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; - operationColumn?: string; - joinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; - splitByCategory?: string; - splitByCategoryLimit?: number; - splitByCategoryValues?: string[]; -} +export type QuerySourceOptions = { + /** + * The column name and the type of geospatial support. + * + * If not present, defaults to `'geom'` for generic queries, `'quadbin'` for Quadbin sources and `'h3'` for H3 sources. + */ + spatialDataColumn?: string; + + /** SQL query. */ + sqlQuery: string; + + /** + * Relative resolution of a tile. Higher values increase density and data size. At `tileResolution = 1`, tile geometry is + * quantized to a 1024x1024 grid. Increasing or decreasing the resolution will increase or decrease the dimensions of + * the quantization grid proportionately. + * + * Supported `tileResolution` values, with corresponding grid sizes: + * + * - 0.25: 256x256 + * - 0.5: 512x512 + * - 1: 1024x1024 + * - 2: 2048x2048 + * - 4: 4096x4096 + */ + tileResolution?: TileResolution; + + /** + * Values for named or positional paramteres in the query. + * + * The way query parameters are determined by data warehouse. + * + * * BigQuery has named query parameters, specified with a dictionary, and referenced by key (`@key`) + * + * ``` + * sqlQuery: "SELECT * FROM carto-demo-data.demo_tables.retail_stores WHERE storetype = ⁣@type AND revenue > ⁣@minRevenue" + * queryParameters: { type: 'Supermarket', minRevenue: 1000000 } + * ``` + * * Snowflake supports positional parameters, in the form `:1`, `:2`, etc. + * + * ``` + * sqlQuery: "SELECT * FROM demo_db.public.import_retail_stores WHERE storetype = :2 AND revenue > :1 + * queryParameters: [100000, "Supermarket"] + * ``` + * * Postgres and Redhisft supports positional parameters, but in the form `$1`, `$2`, etc. + * + * ``` + * sqlQuery: "SELECT * FROM carto_demo_data.demo_tables.retail_stores WHERE storetype = $2 AND revenue > $1 + * queryParameters: [100000, "Supermarket"] + * ``` + */ + queryParameters?: QueryParameters; +}; -/****************************************************************************** - * WIDGET API RESPONSES - */ +export type TableSourceOptions = { + /** + * Fully qualified name of table. + */ + tableName: string; + + /** + * The column name and the type of geospatial support. + * + * If not present, defaults to `'geom'` for generic tables, `'quadbin'` for Quadbin sources and `'h3'` for H3 sources. + */ + spatialDataColumn?: string; + + /** + * Relative resolution of a tile. Higher values increase density and data size. At `tileResolution = 1`, tile geometry is + * quantized to a 1024x1024 grid. Increasing or decreasing the resolution will increase or decrease the dimensions of + * the quantization grid proportionately. + * + * Supported `tileResolution` values, with corresponding grid sizes: + * + * - 0.25: 256x256 + * - 0.5: 512x512 + * - 1: 1024x1024 + * - 2: 2048x2048 + * - 4: 4096x4096 + */ + tileResolution?: TileResolution; +}; -/** Response from {@link WidgetBaseSource#getFormula}. */ -export type FormulaResponse = {value: number}; +export type TilesetSourceOptions = { + /** + * Fully qualified name of tileset. + */ + tableName: string; +}; -/** Response from {@link WidgetBaseSource#getCategories}. */ -export type CategoryResponse = {name: string; value: number}[]; +export type ColumnsOption = { + /** + * Columns to retrieve from the table. + * + * If not present, all columns are returned. + */ + columns?: string[]; +}; -/** Response from {@link WidgetBaseSource#getRange}. */ -export type RangeResponse = {min: number; max: number}; +export type SpatialDataType = 'geo' | 'h3' | 'quadbin'; -/** Response from {@link WidgetBaseSource#getTable}. */ -export type TableResponse = { - totalCount: number; - rows: Record[]; +export type TilejsonMapInstantiation = MapInstantiation & { + tilejson: {url: string[]}; }; -/** Response from {@link WidgetBaseSource#getScatter}. */ -export type ScatterResponse = [number, number][]; +export type TileResolution = 0.25 | 0.5 | 1 | 2 | 4; + +export interface Tilejson { + tilejson: string; + name: string; + description: string; + version: string; + attribution: string; + scheme: string; + tiles: string[]; + properties_tiles: string[]; + minresolution: number; + maxresolution: number; + minzoom: number; + maxzoom: number; + bounds: [number, number, number, number]; + center: [number, number, number]; + vector_layers: VectorLayer[]; + tilestats: Tilestats; + tileResolution?: TileResolution; +} -/** Response from {@link WidgetBaseSource#getTimeSeries}. */ -export type TimeSeriesResponse = { - rows: {name: string; value: number}[]; - categories: string[]; -}; +export interface Tilestats { + layerCount: number; + layers: Layer[]; +} -/** Response from {@link WidgetBaseSource#getHistogram}. */ -export type HistogramResponse = number[]; +export interface Layer { + layer: string; + count: number; + attributeCount: number; + attributes: Attribute[]; +} + +export interface Attribute { + attribute: string; + type: string; +} + +export interface VectorLayer { + id: string; + minzoom: number; + maxzoom: number; + fields: Record; +} + +export type TilejsonResult = Tilejson & {accessToken: string}; +export type GeojsonResult = {type: 'FeatureCollection'; features: Feature[]}; +export type JsonResult = any[]; +export type QueryResult = { + meta: {cacheHit: boolean; location: string; totalBytesProcessed: string}; + rows: Record[]; + schema: {name: string; type: string}[]; +}; diff --git a/src/sources/vector-query-source.ts b/src/sources/vector-query-source.ts new file mode 100644 index 0000000..93db9a4 --- /dev/null +++ b/src/sources/vector-query-source.ts @@ -0,0 +1,70 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable camelcase */ +import {DEFAULT_TILE_RESOLUTION} from '../constants-internal.js'; +import { + WidgetQuerySource, + WidgetQuerySourceResult, +} from '../widget-sources/index.js'; +import {baseSource} from './base-source'; +import type { + FilterOptions, + SourceOptions, + QuerySourceOptions, + SpatialDataType, + TilejsonResult, + ColumnsOption, +} from './types'; + +export type VectorQuerySourceOptions = SourceOptions & + QuerySourceOptions & + FilterOptions & + ColumnsOption; + +type UrlParameters = { + columns?: string; + filters?: Record; + spatialDataType: SpatialDataType; + spatialDataColumn?: string; + tileResolution?: string; + q: string; + queryParameters?: Record | unknown[]; +}; + +export const vectorQuerySource = async function ( + options: VectorQuerySourceOptions +): Promise { + const { + columns, + filters, + spatialDataColumn = 'geom', + sqlQuery, + tileResolution = DEFAULT_TILE_RESOLUTION, + queryParameters, + } = options; + + const urlParameters: UrlParameters = { + spatialDataColumn, + spatialDataType: 'geo', + tileResolution: tileResolution.toString(), + q: sqlQuery, + }; + + if (columns) { + urlParameters.columns = columns.join(','); + } + if (filters) { + urlParameters.filters = filters; + } + if (queryParameters) { + urlParameters.queryParameters = queryParameters; + } + return baseSource('query', options, urlParameters).then( + (result) => ({ + ...(result as TilejsonResult), + widgetSource: new WidgetQuerySource(options), + }) + ); +}; diff --git a/src/sources/vector-table-source.ts b/src/sources/vector-table-source.ts new file mode 100644 index 0000000..85dc4db --- /dev/null +++ b/src/sources/vector-table-source.ts @@ -0,0 +1,64 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable camelcase */ +import {DEFAULT_TILE_RESOLUTION} from '../constants-internal.js'; +import { + WidgetTableSource, + WidgetTableSourceResult, +} from '../widget-sources/index.js'; +import {baseSource} from './base-source'; +import type { + FilterOptions, + ColumnsOption, + SourceOptions, + SpatialDataType, + TableSourceOptions, + TilejsonResult, +} from './types'; + +export type VectorTableSourceOptions = SourceOptions & + TableSourceOptions & + FilterOptions & + ColumnsOption; +type UrlParameters = { + columns?: string; + filters?: Record; + spatialDataType: SpatialDataType; + spatialDataColumn?: string; + tileResolution?: string; + name: string; +}; + +export const vectorTableSource = async function ( + options: VectorTableSourceOptions +): Promise { + const { + columns, + filters, + spatialDataColumn = 'geom', + tableName, + tileResolution = DEFAULT_TILE_RESOLUTION, + } = options; + + const urlParameters: UrlParameters = { + name: tableName, + spatialDataColumn, + spatialDataType: 'geo', + tileResolution: tileResolution.toString(), + }; + + if (columns) { + urlParameters.columns = columns.join(','); + } + if (filters) { + urlParameters.filters = filters; + } + return baseSource('table', options, urlParameters).then( + (result) => ({ + ...(result as TilejsonResult), + widgetSource: new WidgetTableSource(options), + }) + ); +}; diff --git a/src/sources/vector-tileset-source.ts b/src/sources/vector-tileset-source.ts new file mode 100644 index 0000000..506f64d --- /dev/null +++ b/src/sources/vector-tileset-source.ts @@ -0,0 +1,26 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {baseSource} from './base-source'; +import type { + SourceOptions, + TilesetSourceOptions, + TilejsonResult, +} from './types'; + +export type VectorTilesetSourceOptions = SourceOptions & TilesetSourceOptions; +type UrlParameters = {name: string}; + +export const vectorTilesetSource = async function ( + options: VectorTilesetSourceOptions +): Promise { + const {tableName} = options; + const urlParameters: UrlParameters = {name: tableName}; + + return baseSource( + 'tileset', + options, + urlParameters + ) as Promise; +}; diff --git a/src/sources/wrappers.ts b/src/sources/wrappers.ts deleted file mode 100644 index 9027841..0000000 --- a/src/sources/wrappers.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - h3TableSource as _h3TableSource, - h3QuerySource as _h3QuerySource, - vectorTableSource as _vectorTableSource, - vectorQuerySource as _vectorQuerySource, - quadbinTableSource as _quadbinTableSource, - quadbinQuerySource as _quadbinQuerySource, - VectorTableSourceOptions as _VectorTableSourceOptions, - VectorQuerySourceOptions as _VectorQuerySourceOptions, - H3TableSourceOptions as _H3TableSourceOptions, - H3QuerySourceOptions as _H3QuerySourceOptions, - QuadbinTableSourceOptions as _QuadbinTableSourceOptions, - QuadbinQuerySourceOptions as _QuadbinQuerySourceOptions, - SourceOptions, -} from '@deck.gl/carto'; -import {WidgetBaseSourceProps} from './widget-base-source.js'; -import {WidgetQuerySource} from './widget-query-source.js'; -import {WidgetTableSource} from './widget-table-source.js'; - -type WrappedSourceOptions = Omit & WidgetBaseSourceProps; - -/****************************************************************************** - * RESPONSE OBJECTS - */ - -type WidgetTableSourceResponse = {widgetSource: WidgetTableSource}; -type WidgetQuerySourceResponse = {widgetSource: WidgetQuerySource}; - -export type VectorTableSourceResponse = WidgetTableSourceResponse & - Awaited>; -export type VectorQuerySourceResponse = WidgetQuerySourceResponse & - Awaited>; - -export type H3TableSourceResponse = WidgetTableSourceResponse & - Awaited>; -export type H3QuerySourceResponse = WidgetQuerySourceResponse & - Awaited>; - -export type QuadbinTableSourceResponse = WidgetTableSourceResponse & - Awaited>; -export type QuadbinQuerySourceResponse = WidgetQuerySourceResponse & - Awaited>; - -/****************************************************************************** - * VECTOR SOURCES - */ - -export type VectorTableSourceOptions = - WrappedSourceOptions<_VectorTableSourceOptions>; - -export type VectorQuerySourceOptions = - WrappedSourceOptions<_VectorQuerySourceOptions>; - -/** Wrapper adding Widget API support to [vectorTableSource](https://deck.gl/docs/api-reference/carto/data-sources). */ -export async function vectorTableSource( - props: VectorTableSourceOptions -): Promise { - assignDefaultProps(props); - const response = await _vectorTableSource(props as _VectorTableSourceOptions); - return {...response, widgetSource: new WidgetTableSource(props)}; -} - -/** Wrapper adding Widget API support to [vectorQuerySource](https://deck.gl/docs/api-reference/carto/data-sources). */ -export async function vectorQuerySource( - props: VectorQuerySourceOptions -): Promise { - assignDefaultProps(props); - const response = await _vectorQuerySource(props as _VectorQuerySourceOptions); - return {...response, widgetSource: new WidgetQuerySource(props)}; -} - -/****************************************************************************** - * H3 SOURCES - */ - -export type H3TableSourceOptions = WrappedSourceOptions<_H3TableSourceOptions>; -export type H3QuerySourceOptions = WrappedSourceOptions<_H3QuerySourceOptions>; - -/** Wrapper adding Widget API support to [h3TableSource](https://deck.gl/docs/api-reference/carto/data-sources). */ -export async function h3TableSource( - props: H3TableSourceOptions -): Promise { - assignDefaultProps(props); - const response = await _h3TableSource(props as _H3TableSourceOptions); - return {...response, widgetSource: new WidgetTableSource(props)}; -} - -/** Wrapper adding Widget API support to [h3QuerySource](https://deck.gl/docs/api-reference/carto/data-sources). */ -export async function h3QuerySource( - props: H3QuerySourceOptions -): Promise { - assignDefaultProps(props); - const response = await _h3QuerySource(props as _H3QuerySourceOptions); - return {...response, widgetSource: new WidgetQuerySource(props)}; -} - -/****************************************************************************** - * QUADBIN SOURCES - */ - -export type QuadbinTableSourceOptions = - WrappedSourceOptions<_QuadbinTableSourceOptions>; - -export type QuadbinQuerySourceOptions = - WrappedSourceOptions<_QuadbinQuerySourceOptions>; - -/** Wrapper adding Widget API support to [quadbinTableSource](https://deck.gl/docs/api-reference/carto/data-sources). */ -export async function quadbinTableSource( - props: QuadbinTableSourceOptions & WidgetBaseSourceProps -): Promise { - assignDefaultProps(props); - const response = await _quadbinTableSource( - props as _QuadbinTableSourceOptions - ); - return {...response, widgetSource: new WidgetTableSource(props)}; -} - -/** Wrapper adding Widget API support to [quadbinQuerySource](https://deck.gl/docs/api-reference/carto/data-sources). */ -export async function quadbinQuerySource( - props: QuadbinQuerySourceOptions & WidgetBaseSourceProps -): Promise { - assignDefaultProps(props); - const response = await _quadbinQuerySource( - props as _QuadbinQuerySourceOptions - ); - return {...response, widgetSource: new WidgetQuerySource(props)}; -} - -/****************************************************************************** - * DEFAULT PROPS - */ - -declare const deck: {VERSION?: string} | undefined; -function assignDefaultProps(props: T): void { - if (typeof deck !== 'undefined' && deck && deck.VERSION) { - props.clientId ||= 'deck-gl-carto'; - // TODO: Uncomment if/when `@deck.gl/carto` devDependency is removed, - // and source functions are moved here rather than wrapped. - // props.deckglVersion ||= deck.VERSION; - } -} diff --git a/src/types-internal.ts b/src/types-internal.ts index 604a928..2bf5f4a 100644 --- a/src/types-internal.ts +++ b/src/types-internal.ts @@ -1,9 +1,62 @@ /****************************************************************************** - * INTERNAL + * COMMON */ +import {Format} from './types'; + /** @internal */ export type $TODO = any; /** @internal */ export type $IntentionalAny = any; + +/****************************************************************************** + * MAP INSTANTIATION + */ + +/** + * @internalRemarks Source: @deck.gl/carto + * @internal + */ +export enum SchemaFieldType { + Number = 'number', + Bigint = 'bigint', + String = 'string', + Geometry = 'geometry', + Timestamp = 'timestamp', + Object = 'object', + Boolean = 'boolean', + Variant = 'variant', + Unknown = 'unknown', +} + +/** + * @internalRemarks Source: @deck.gl/carto + * @internal + */ +export interface SchemaField { + name: string; + type: SchemaFieldType; // Field type in the CARTO stack, common for all providers +} + +/** + * @internalRemarks Source: @deck.gl/carto + * @internal + */ +export interface MapInstantiation extends MapInstantiationFormats { + nrows: number; + size?: number; + schema: SchemaField[]; +} + +/** + * @internalRemarks Source: @deck.gl/carto + * @internal + */ +type MapInstantiationFormats = Record< + Format, + { + url: string[]; + error?: any; + } +>; diff --git a/src/types.ts b/src/types.ts index c2d4ffe..984b07d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,16 @@ import type {FilterType} from './constants.js'; import type {Polygon, MultiPolygon} from 'geojson'; +/****************************************************************************** + * MAPS AND TILES + */ + +/** @internalRemarks Source: @deck.gl/carto */ +export type Format = 'json' | 'geojson' | 'tilejson'; + +/** @internalRemarks Source: @carto/constants, @deck.gl/carto */ +export type MapType = 'boundary' | 'query' | 'table' | 'tileset' | 'raster'; + /****************************************************************************** * AGGREGATION */ @@ -26,6 +36,11 @@ export type AggregationType = /** @internalRemarks Source: @carto/react-api */ export type SpatialFilter = Polygon | MultiPolygon; +/** @internalRemarks Source: @deck.gl/carto */ +export interface Filters { + [column: string]: Filter; +} + /** @internalRemarks Source: @carto/react-api, @deck.gl/carto */ export interface Filter { [FilterType.IN]?: {owner?: string; values: number[] | string[]}; diff --git a/src/utils.ts b/src/utils.ts index fc756c0..e4048fc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import {Filter} from './types.js'; import {FilterType} from './constants.js'; -import {$TODO} from './types-internal.js'; const FILTER_TYPES = new Set(Object.values(FilterType)); const isFilterType = (type: string): type is FilterType => @@ -26,7 +25,7 @@ export function getApplicableFilters( const isApplicable = !owner || !filter?.owner || filter?.owner !== owner; if (filter && isApplicable) { applicableFilters[column] ||= {}; - applicableFilters[column][type] = filter as $TODO; + (applicableFilters[column][type] as typeof filter) = filter; } } } @@ -90,3 +89,11 @@ export function isEmptyObject(object: object): boolean { } return true; } + +/** @internal */ +export const isObject: (x: unknown) => boolean = (x) => + x !== null && typeof x === 'object'; + +/** @internal */ +export const isPureObject: (x: any) => boolean = (x) => + isObject(x) && x.constructor === {}.constructor; diff --git a/src/widget-sources/index.ts b/src/widget-sources/index.ts new file mode 100644 index 0000000..ff13155 --- /dev/null +++ b/src/widget-sources/index.ts @@ -0,0 +1,4 @@ +export * from './widget-base-source.js'; +export * from './widget-query-source.js'; +export * from './widget-table-source.js'; +export * from './types.js'; diff --git a/src/widget-sources/types.ts b/src/widget-sources/types.ts new file mode 100644 index 0000000..05b035c --- /dev/null +++ b/src/widget-sources/types.ts @@ -0,0 +1,105 @@ +import { + GroupDateType, + SortColumnType, + SortDirection, + SpatialFilter, +} from '../types'; + +/****************************************************************************** + * WIDGET API REQUESTS + */ + +/** Common options for {@link WidgetBaseSource} requests. */ +interface BaseRequestOptions { + spatialFilter?: SpatialFilter; + abortController?: AbortController; + filterOwner?: string; +} + +/** Options for {@link WidgetBaseSource#getCategories}. */ +export interface CategoryRequestOptions extends BaseRequestOptions { + column: string; + operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; + operationColumn?: string; +} + +/** Options for {@link WidgetBaseSource#getFormula}. */ +export interface FormulaRequestOptions extends BaseRequestOptions { + column: string; + operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; + operationExp?: string; +} + +/** Options for {@link WidgetBaseSource#getHistogram}. */ +export interface HistogramRequestOptions extends BaseRequestOptions { + column: string; + ticks: number[]; + operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; +} + +/** Options for {@link WidgetBaseSource#getRange}. */ +export interface RangeRequestOptions extends BaseRequestOptions { + column: string; +} + +/** Options for {@link WidgetBaseSource#getScatter}. */ +export interface ScatterRequestOptions extends BaseRequestOptions { + xAxisColumn: string; + xAxisJoinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; + yAxisColumn: string; + yAxisJoinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; +} + +/** Options for {@link WidgetBaseSource#getTable}. */ +export interface TableRequestOptions extends BaseRequestOptions { + columns: string[]; + sortBy?: string; + sortDirection?: SortDirection; + sortByColumnType?: SortColumnType; + offset?: number; + limit?: number; +} + +/** Options for {@link WidgetBaseSource#getTimeSeries}. */ +export interface TimeSeriesRequestOptions extends BaseRequestOptions { + column: string; + stepSize?: GroupDateType; + stepMultiplier?: number; + operation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; + operationColumn?: string; + joinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum'; + splitByCategory?: string; + splitByCategoryLimit?: number; + splitByCategoryValues?: string[]; +} + +/****************************************************************************** + * WIDGET API RESPONSES + */ + +/** Response from {@link WidgetBaseSource#getFormula}. */ +export type FormulaResponse = {value: number}; + +/** Response from {@link WidgetBaseSource#getCategories}. */ +export type CategoryResponse = {name: string; value: number}[]; + +/** Response from {@link WidgetBaseSource#getRange}. */ +export type RangeResponse = {min: number; max: number}; + +/** Response from {@link WidgetBaseSource#getTable}. */ +export type TableResponse = { + totalCount: number; + rows: Record[]; +}; + +/** Response from {@link WidgetBaseSource#getScatter}. */ +export type ScatterResponse = [number, number][]; + +/** Response from {@link WidgetBaseSource#getTimeSeries}. */ +export type TimeSeriesResponse = { + rows: {name: string; value: number}[]; + categories: string[]; +}; + +/** Response from {@link WidgetBaseSource#getHistogram}. */ +export type HistogramResponse = number[]; diff --git a/src/sources/widget-base-source.ts b/src/widget-sources/widget-base-source.ts similarity index 98% rename from src/sources/widget-base-source.ts rename to src/widget-sources/widget-base-source.ts index 0b3d787..3dde07d 100644 --- a/src/sources/widget-base-source.ts +++ b/src/widget-sources/widget-base-source.ts @@ -16,15 +16,12 @@ import { TimeSeriesResponse, } from './types.js'; import {FilterLogicalOperator, Filter} from '../types.js'; -import {SourceOptions} from '@deck.gl/carto'; import {getApplicableFilters, normalizeObjectKeys} from '../utils.js'; -import { - DEFAULT_API_BASE_URL, - DEFAULT_GEO_COLUMN, - ApiVersion, -} from '../constants-internal.js'; import {getClient} from '../client.js'; import {ModelSource} from '../models/model.js'; +import {SourceOptions} from '../sources/index.js'; +import {ApiVersion, DEFAULT_API_BASE_URL} from '../constants.js'; +import {DEFAULT_GEO_COLUMN} from '../constants-internal.js'; export interface WidgetBaseSourceProps extends Omit { apiVersion?: ApiVersion; diff --git a/src/sources/widget-query-source.ts b/src/widget-sources/widget-query-source.ts similarity index 91% rename from src/sources/widget-query-source.ts rename to src/widget-sources/widget-query-source.ts index 3c71998..9c82f24 100644 --- a/src/sources/widget-query-source.ts +++ b/src/widget-sources/widget-query-source.ts @@ -2,8 +2,7 @@ import { H3QuerySourceOptions, QuadbinQuerySourceOptions, VectorQuerySourceOptions, -} from '@deck.gl/carto'; -import {MapType} from '../constants-internal.js'; +} from '../sources/index.js'; import {WidgetBaseSource, WidgetBaseSourceProps} from './widget-base-source.js'; import {ModelSource} from '../models/model.js'; @@ -12,6 +11,8 @@ type LayerQuerySourceOptions = | Omit | Omit; +export type WidgetQuerySourceResult = {widgetSource: WidgetQuerySource}; + /** * Source for Widget API requests on a data source defined by a SQL query. * @@ -40,7 +41,7 @@ export class WidgetQuerySource extends WidgetBaseSource< protected override getModelSource(owner: string): ModelSource { return { ...super._getModelSource(owner), - type: MapType.QUERY, + type: 'query', data: this.props.sqlQuery, queryParameters: this.props.queryParameters, }; diff --git a/src/sources/widget-table-source.ts b/src/widget-sources/widget-table-source.ts similarity index 91% rename from src/sources/widget-table-source.ts rename to src/widget-sources/widget-table-source.ts index 8b5df16..28dc5d1 100644 --- a/src/sources/widget-table-source.ts +++ b/src/widget-sources/widget-table-source.ts @@ -2,9 +2,8 @@ import { H3TableSourceOptions, QuadbinTableSourceOptions, VectorTableSourceOptions, -} from '@deck.gl/carto'; +} from '../sources/index.js'; import {WidgetBaseSource, WidgetBaseSourceProps} from './widget-base-source.js'; -import {MapType} from '../constants-internal.js'; import {ModelSource} from '../models/model.js'; type LayerTableSourceOptions = @@ -12,6 +11,8 @@ type LayerTableSourceOptions = | Omit | Omit; +export type WidgetTableSourceResult = {widgetSource: WidgetTableSource}; + /** * Source for Widget API requests on a data source defined as a table. * @@ -40,7 +41,7 @@ export class WidgetTableSource extends WidgetBaseSource< protected override getModelSource(owner: string): ModelSource { return { ...super._getModelSource(owner), - type: MapType.TABLE, + type: 'table', data: this.props.tableName, }; } diff --git a/test/__mock-fetch.ts b/test/__mock-fetch.ts new file mode 100644 index 0000000..023ffd9 --- /dev/null +++ b/test/__mock-fetch.ts @@ -0,0 +1,147 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* global Headers */ + +// See test/modules/carto/responseToJson for details for creating test data +import binaryTileData from './data/binaryTile.json'; +const BINARY_TILE = new Uint8Array(binaryTileData).buffer; + +const fetch = globalThis.fetch; +type MockFetchCall = { + url: string; + headers: Record; + method?: 'GET' | 'POST'; + body?: string; +}; + +export const TILEJSON_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: { + layers: [ + { + attributes: [ + {attribute: 'population', type: 'integer'}, + {attribute: 'category', type: 'string'}, + ], + }, + ], + }, +}; + +export const GEOJSON_RESPONSE = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [-6.7531585693359375, 37.57505900514996], + }, + }, + ], +}; + +export const TILESTATS_RESPONSE = { + attribute: 'population', + avg: 10, + min: 1, + max: 20, + quantiles: [], + sum: 100, + type: 'Number', +}; + +export const QUERY_RESPONSE = [{id: 1, value: 'string'}]; + +const createDefaultResponse = ( + url: string, + headers: HeadersInit, + cacheKey?: string +): Promise => { + return Promise.resolve({ + json: () => { + if (url.indexOf('format=tilejson') !== -1) { + return TILEJSON_RESPONSE; + } + if (url.indexOf('format=geojson') !== -1) { + return GEOJSON_RESPONSE; + } + + if (url.indexOf('tileset') !== -1) { + return { + tilejson: { + url: [`https://xyz.com?format=tilejson&cache=${cacheKey}`], + }, + }; + } + if (url.indexOf('stats') !== -1) { + return TILESTATS_RESPONSE; + } + if (url.indexOf('sql') !== -1) { + return QUERY_RESPONSE; + } + if (url.indexOf('query') !== -1 || url.indexOf('table')) { + return { + tilejson: { + url: [`https://xyz.com?format=tilejson&cache=${cacheKey}`], + }, + geojson: { + url: [`https://xyz.com?format=geojson&cache=${cacheKey}`], + }, + }; + } + return null; + }, + arrayBuffer: () => BINARY_TILE, + text: () => null, // Required to get loaders.gl to use arrayBuffer() + ok: true, + url, + headers: new Headers(headers), + }); +}; + +async function setupMockFetchMapsV3( + responseFunc = createDefaultResponse, + cacheKey = btoa(Math.random().toFixed(4)) +): Promise { + const calls: MockFetchCall[] = []; + + const mockFetch = (url: string, {headers, method, body}) => { + calls.push({url, headers, method, body}); + if (url.indexOf('formatTiles=binary') !== -1) { + headers = { + ...headers, + 'Content-Type': 'application/vnd.carto-vector-tile', + }; + } + return responseFunc(url, headers, cacheKey); + }; + + globalThis.fetch = mockFetch as unknown as typeof fetch; + + return calls; +} + +function teardownMockFetchMaps() { + globalThis.fetch = fetch; +} + +export async function withMockFetchMapsV3( + testFunc: (calls: MockFetchCall[]) => Promise, + responseFunc: ( + url: string, + headers: HeadersInit, + cacheKey?: string + ) => Promise = createDefaultResponse +): Promise { + try { + const calls = await setupMockFetchMapsV3(responseFunc); + await testFunc(calls); + } finally { + teardownMockFetchMaps(); + } +} diff --git a/test/api/carto-api-error.test.ts b/test/api/carto-api-error.test.ts new file mode 100644 index 0000000..b9fa8f7 --- /dev/null +++ b/test/api/carto-api-error.test.ts @@ -0,0 +1,117 @@ +import {expect, test} from 'vitest'; +import {APIErrorContext, CartoAPIError} from '@carto/api-client'; + +[ + { + title: '400 error', + error: new Error('Bad'), + errorContext: {requestType: 'Map data'}, + response: {status: 400}, + message: ` +Map data API request failed +Server returned: Bad request (400): Bad +`, + }, + { + title: '401 error', + error: new Error('Unauthorized'), + errorContext: {requestType: 'Map data'}, + response: {status: 401}, + message: ` +Map data API request failed +Server returned: Unauthorized access (401): Unauthorized +`, + }, + { + title: '403 error', + error: new Error('Forbidden'), + errorContext: {requestType: 'Map data'}, + response: {status: 403}, + message: ` +Map data API request failed +Server returned: Unauthorized access (403): Forbidden +`, + }, + { + title: '404 error', + error: new Error('Not found'), + errorContext: {requestType: 'Map data'}, + response: {status: 404}, + message: ` +Map data API request failed +Server returned: Not found (404): Not found +`, + }, + { + title: '500 error', + error: new Error('Source error'), + errorContext: {requestType: 'Map data'}, + response: {status: 500}, + message: ` +Map data API request failed +Server returned: Error (500): Source error +`, + }, + { + title: 'Full error context: instantiation', + error: new Error('Source error'), + errorContext: { + requestType: 'Map instantiation', + connection: 'connectionName', + source: 'sourceName', + type: 'query', + }, + response: {status: 500}, + message: ` +Map instantiation API request failed +Server returned: Error (500): Source error +Connection: connectionName +Source: sourceName +Type: query +`, + }, + { + title: 'Full error context: public map', + error: new Error('Source error'), + errorContext: { + requestType: 'Public map', + mapId: 'abcd', + }, + response: {status: 500}, + message: ` +Public map API request failed +Server returned: Error (500): Source error +Map Id: abcd +`, + }, + { + title: 'Full error context: custom value', + error: new Error('Source error'), + errorContext: { + requestType: 'Tile stats', + connection: 'connectionName', + source: 'sourceName', + type: 'tileset', + customKey: 'customValue', + }, + response: {status: 500}, + message: ` +Tile stats API request failed +Server returned: Error (500): Source error +Connection: connectionName +Source: sourceName +Type: tileset +Custom Key: customValue +`, + }, +].forEach(({title, error, errorContext, response, message}) => { + test(`CartoAPIError: ${title}`, () => { + const cartoAPIError = new CartoAPIError( + error, + errorContext as APIErrorContext, + response as Response + ); + expect(cartoAPIError).toBeTruthy(); + expect(cartoAPIError.message).toBe(message.slice(1)); + }); +}); diff --git a/test/api/query.test.ts b/test/api/query.test.ts new file mode 100644 index 0000000..fc2d08a --- /dev/null +++ b/test/api/query.test.ts @@ -0,0 +1,37 @@ +import {test, vi, expect, beforeEach, afterEach} from 'vitest'; +import {query} from '@carto/api-client'; + +const QUERY_RESPONSE = [{id: 1, value: 'string'}]; + +beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValue( + Promise.resolve({ok: true, json: () => Promise.resolve(QUERY_RESPONSE)}) + ); + + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => void vi.restoreAllMocks()); + +test('query', async () => { + const mockFetch = vi.mocked(fetch); + + const response = await query({ + connectionName: 'carto_dw', + clientId: 'CUSTOM_CLIENT', + accessToken: '', + sqlQuery: 'SELECT * FROM a.b.h3_table', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [queryCall] = mockFetch.mock.calls; + + expect(queryCall[0]).toMatch(/v3\/sql\/carto_dw\/query/); + expect(queryCall[0]).toMatch(/q=SELECT\+\*\+FROM\+a\.b\.h3_table/); + expect(queryCall[0]).toMatch(/client\=CUSTOM_CLIENT/); + + expect(response).toEqual(QUERY_RESPONSE); +}); diff --git a/test/api/request-with-parameters.test.ts b/test/api/request-with-parameters.test.ts new file mode 100644 index 0000000..0a31236 --- /dev/null +++ b/test/api/request-with-parameters.test.ts @@ -0,0 +1,289 @@ +import { + describe, + test, + expect, + vi, + afterEach, + beforeEach, + assert, +} from 'vitest'; +import { + APIRequestType, + CartoAPIError, + requestWithParameters, +} from '@carto/api-client'; + +const errorContext = {requestType: 'test' as APIRequestType}; + +describe('requestWithParameters', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValue( + Promise.resolve({ok: true, json: () => Promise.resolve({data: 12345})}) + ); + + vi.stubGlobal('fetch', mockFetch); + vi.stubGlobal('deck', {VERSION: 'untranspiled source'}); + }); + afterEach(() => void vi.restoreAllMocks()); + + test('cache baseURL', async () => { + const mockFetch = vi.mocked(fetch); + + expect(mockFetch).not.toHaveBeenCalled(); + + await Promise.all([ + requestWithParameters({ + baseUrl: 'https://example.com/v1/baseURL', + headers: {}, + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v2/baseURL', + headers: {}, + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v2/baseURL', + headers: {}, + errorContext, + }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('cache headers', async () => { + const mockFetch = vi.mocked(fetch); + + expect(mockFetch).not.toHaveBeenCalled(); + + await Promise.all([ + requestWithParameters({ + baseUrl: 'https://example.com/v1/headers', + headers: {key: '1'}, + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v1/headers', + headers: {key: '1'}, + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v1/headers', + headers: {key: '2'}, + errorContext, + }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('cache parameters', async () => { + const mockFetch = vi.mocked(fetch); + + expect(mockFetch).not.toHaveBeenCalled(); + + await Promise.all([ + requestWithParameters({ + baseUrl: 'https://example.com/v1/params', + headers: {}, + parameters: {}, + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v1/params', + headers: {}, + parameters: {}, + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v1/params', + headers: {}, + parameters: {a: 1}, + errorContext, + }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('no cache error context', async () => { + const mockFetch = vi + .mocked(fetch) + .mockReset() + .mockReturnValue( + // @ts-ignore + Promise.resolve({ + ok: false, + json: () => + Promise.resolve({error: 'CustomError', customData: {abc: 'def'}}), + }) + ); + + expect(mockFetch).not.toHaveBeenCalled(); + + let error1: Error | undefined; + let error2: Error | undefined; + + try { + await requestWithParameters({ + baseUrl: 'https://example.com/v1/errorContext', + errorContext: {requestType: 'Map data'}, + }); + assert.fail('request #1 should fail, but did not'); + } catch (error) { + error1 = error as Error; + } + + try { + await requestWithParameters({ + baseUrl: 'https://example.com/v1/errorContext', + errorContext: {requestType: 'SQL'}, + }); + assert.fail('request #2 should fail, but did not'); + } catch (error) { + error2 = error as Error; + } + + // 2 unique requests, failures not cached + expect(mockFetch).toHaveBeenCalledTimes(2); + + expect((error1 as CartoAPIError).responseJson).toMatchObject({ + error: 'CustomError', + customData: {abc: 'def'}, + }); + + expect(error1 instanceof CartoAPIError).toBeTruthy(); + expect((error1 as CartoAPIError).errorContext.requestType).toBe('Map data'); + expect(error2 instanceof CartoAPIError).toBeTruthy(); + expect((error2 as CartoAPIError).errorContext.requestType).toBe('SQL'); + }); + + test('method GET or POST', async () => { + const mockFetch = vi.mocked(fetch); + + expect(mockFetch).not.toHaveBeenCalled(); + + await Promise.all([ + requestWithParameters({ + baseUrl: 'https://example.com/v1/params', + headers: {}, + parameters: {object: {a: 1, b: 2}, array: [1, 2, 3], string: 'short'}, + errorContext, + }), + requestWithParameters({ + baseUrl: `https://example.com/v1/params`, + headers: {}, + parameters: { + object: {a: 1, b: 2}, + array: [1, 2, 3], + string: 'long'.padEnd(10_000, 'g'), + }, + errorContext, + }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const calls = mockFetch.mock.calls; + + // GET + expect(calls[0][0]).toMatch(/^https:\/\/example\.com\/v1\/params\?/); + expect(calls[0][1].method).toBe(undefined); + expect(calls[0][1].body).toBe(undefined); + expect( + Array.from(new URL(calls[0][0] as string).searchParams.entries()) + ).toEqual([ + ['v', '3.4'], + ['client', 'deck-gl-carto'], + ['deckglVersion', 'untranspiled source'], + ['object', '{"a":1,"b":2}'], + ['array', '[1,2,3]'], + ['string', 'short'], + ]); + + // POST + const postBody = JSON.parse(calls[1][1].body as string); + expect(calls[1][1].method).toBe('POST'); + expect(postBody.v).toBe('3.4'); + expect(postBody.deckglVersion).toBe('untranspiled source'); + expect(postBody.object).toEqual({a: 1, b: 2}); + expect(postBody.array).toEqual([1, 2, 3]); + expect(postBody.string).toMatch(/^longgg/); + expect(calls[1][0]).toBe('https://example.com/v1/params'); + }); + + test('parameter precedence', async () => { + const mockFetch = vi.mocked(fetch); + + expect(mockFetch).not.toHaveBeenCalled(); + + await Promise.all([ + requestWithParameters({ + baseUrl: 'https://example.com/v1/params?test=1', + headers: {}, + parameters: {}, + errorContext, + }), + requestWithParameters({ + baseUrl: `https://example.com/v1/params?test=2&v=3.0`, + headers: {}, + parameters: {}, + errorContext, + }), + requestWithParameters({ + baseUrl: `https://example.com/v1/params?test=3&v=3.0`, + headers: {}, + parameters: {v: '3.2'}, + errorContext, + }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(3); + + const calls = mockFetch.mock.calls; + const [url1, url2, url3] = calls.map((call) => new URL(call[0] as string)); + + expect(url1.searchParams.get('v')).toBe('3.4'); // unset + expect(url2.searchParams.get('v')).toBe('3.4'); // default overrides url + expect(url3.searchParams.get('v')).toBe('3.2'); // param overrides default + }); + + test('maxLengthURL', async (t) => { + const mockFetch = vi.mocked(fetch); + + expect(mockFetch).not.toHaveBeenCalled(); + + await Promise.all([ + requestWithParameters({ + baseUrl: 'https://example.com/v1/item/1', + errorContext, + }), + requestWithParameters({ + baseUrl: 'https://example.com/v1/item/2', + maxLengthURL: 10, + errorContext, + }), + requestWithParameters({ + baseUrl: `https://example.com/v1/item/3`, + parameters: {content: 'long'.padEnd(10_000, 'g')}, // > default limit + errorContext, + }), + requestWithParameters({ + baseUrl: `https://example.com/v1/item/4`, + parameters: {content: 'long'.padEnd(10_000, 'g')}, + maxLengthURL: 15_000, + errorContext, + }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(4); + + const calls = mockFetch.mock.calls; + const methods = calls.map(([_, {method}]) => method ?? 'GET'); + + expect(methods).toEqual(['GET', 'POST', 'POST', 'GET']); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts index c0d6240..0d4e0ce 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -2,7 +2,7 @@ import {expect, test} from 'vitest'; import {getClient, setClient} from '@carto/api-client'; // Source: src/client.ts -const CLIENT_ID = 'carto-api-client'; +const CLIENT_ID = 'deck-gl-carto'; test('client', () => { expect(getClient()).toBe(CLIENT_ID); diff --git a/test/constants.test.ts b/test/constants.test.ts index 77015e6..0c45544 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -1,8 +1,12 @@ import {expect, test} from 'vitest'; -import {FilterType} from '@carto/api-client'; +import {FilterType, DEFAULT_API_BASE_URL} from '@carto/api-client'; test('FilterType', () => { expect(FilterType.IN).toBe('in'); expect(FilterType.STRING_SEARCH).toBe('stringSearch'); expect(FilterType.CLOSED_OPEN).toBe('closed_open'); }); + +test('DEFAULT_API_BASE_URL', () => { + expect(DEFAULT_API_BASE_URL).toMatch(/^https:\/\//); +}); diff --git a/test/sources/boundary-query-source.test.ts b/test/sources/boundary-query-source.test.ts new file mode 100644 index 0000000..480af3f --- /dev/null +++ b/test/sources/boundary-query-source.test.ts @@ -0,0 +1,65 @@ +import {boundaryQuerySource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'boundary-query-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('boundaryQuerySource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await boundaryQuerySource({ + connectionName: 'carto_dw', + accessToken: '', + tilesetTableName: 'a.b.tileset_table', + columns: ['column1', 'column2'], + propertiesSqlQuery: 'select * from `a.b.properties_table`', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/boundary/); + expect(initURL).toMatch(/tilesetTableName=a.b.tileset_table/); + expect(initURL).toMatch( + /propertiesSqlQuery=select\+\*\+from\+%60a.b.properties_table%60/ + ); + expect(initURL).toMatch(/columns=column1%2Ccolumn2/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); +}); diff --git a/test/sources/boundary-table-source.test.ts b/test/sources/boundary-table-source.test.ts new file mode 100644 index 0000000..785e3eb --- /dev/null +++ b/test/sources/boundary-table-source.test.ts @@ -0,0 +1,63 @@ +import {boundaryTableSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'boundary-table-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('boundaryTableSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await boundaryTableSource({ + connectionName: 'carto_dw', + accessToken: '', + tilesetTableName: 'a.b.tileset_table', + columns: ['column1', 'column2'], + propertiesTableName: 'a.b.properties_table', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/boundary/); + expect(initURL).toMatch(/tilesetTableName=a.b.tileset_table/); + expect(initURL).toMatch(/propertiesTableName=a.b.properties_table/); + expect(initURL).toMatch(/columns=column1%2Ccolumn2/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); +}); diff --git a/test/sources/h3-query-source.test.ts b/test/sources/h3-query-source.test.ts new file mode 100644 index 0000000..9fd1993 --- /dev/null +++ b/test/sources/h3-query-source.test.ts @@ -0,0 +1,75 @@ +import {WidgetQuerySource, h3QuerySource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'h3-query-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('h3QuerySource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + test('default', async () => { + const tilejson = await h3QuerySource({ + connectionName: 'carto_dw', + clientId: 'CUSTOM_CLIENT', + accessToken: '', + sqlQuery: 'SELECT * FROM a.b.h3_table', + aggregationExp: 'SUM(population) as pop', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/query/); + expect(initURL).toMatch(/aggregationExp=SUM%28population%29\+as\+pop/); + expect(initURL).toMatch(/spatialDataColumn=h3/); + expect(initURL).toMatch(/spatialDataType=h3/); + expect(initURL).toMatch(/q=SELECT\+\*\+FROM\+a\.b\.h3_table/); + expect(initURL).toMatch(/client\=CUSTOM_CLIENT/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); + + test('widgetSource', async () => { + const {widgetSource} = await h3QuerySource({ + accessToken: '', + connectionName: 'carto_dw', + sqlQuery: 'SELECT *', + aggregationExp: 'COUNT (*)', + }); + + expect(widgetSource).toBeInstanceOf(WidgetQuerySource); + }); +}); diff --git a/test/sources/h3-table-source.test.ts b/test/sources/h3-table-source.test.ts new file mode 100644 index 0000000..17866b6 --- /dev/null +++ b/test/sources/h3-table-source.test.ts @@ -0,0 +1,76 @@ +import {WidgetTableSource, h3TableSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'h3-table-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('h3TableSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await h3TableSource({ + connectionName: 'carto_dw', + clientId: 'CUSTOM_CLIENT', + accessToken: '', + tableName: 'a.b.h3_table', + aggregationExp: 'SUM(population) as pop', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/table/); + expect(initURL).toMatch(/aggregationExp=SUM%28population%29\+as\+pop/); + expect(initURL).toMatch(/spatialDataColumn=h3/); + expect(initURL).toMatch(/spatialDataType=h3/); + expect(initURL).toMatch(/name=a.b.h3_table/); + expect(initURL).toMatch(/client\=CUSTOM_CLIENT/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); + + test('widgetSource', async () => { + const {widgetSource} = await h3TableSource({ + accessToken: '', + connectionName: 'carto_dw', + tableName: 'my-table', + aggregationExp: 'COUNT (*)', + }); + + expect(widgetSource).toBeInstanceOf(WidgetTableSource); + }); +}); diff --git a/test/sources/h3-tileset-source.test.ts b/test/sources/h3-tileset-source.test.ts new file mode 100644 index 0000000..f7373fb --- /dev/null +++ b/test/sources/h3-tileset-source.test.ts @@ -0,0 +1,59 @@ +import {h3TilesetSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'h3-tileset-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('h3TilesetSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await h3TilesetSource({ + connectionName: 'carto_dw', + accessToken: '', + tableName: 'a.b.h3_tileset', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/tileset/); + expect(initURL).toMatch(/name=a.b.h3_tileset/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); +}); diff --git a/test/sources/quadbin-query-source.test.ts b/test/sources/quadbin-query-source.test.ts new file mode 100644 index 0000000..3b3555a --- /dev/null +++ b/test/sources/quadbin-query-source.test.ts @@ -0,0 +1,74 @@ +import {WidgetQuerySource, quadbinQuerySource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'quadbin-query-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('quadbinQuerySource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await quadbinQuerySource({ + connectionName: 'carto_dw', + accessToken: '', + sqlQuery: 'SELECT * FROM a.b.quadbin_table', + aggregationExp: 'SUM(population) as pop', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/query/); + expect(initURL).toMatch(/aggregationExp=SUM%28population%29\+as\+pop/); + expect(initURL).toMatch(/spatialDataColumn=quadbin/); + expect(initURL).toMatch(/spatialDataType=quadbin/); + expect(initURL).toMatch(/q=SELECT\+\*\+FROM\+a\.b\.quadbin_table/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); + + test('widgetSource', async () => { + const {widgetSource} = await quadbinQuerySource({ + accessToken: '', + connectionName: 'carto_dw', + sqlQuery: 'SELECT *', + aggregationExp: 'COUNT (*)', + }); + + expect(widgetSource).toBeInstanceOf(WidgetQuerySource); + }); +}); diff --git a/test/sources/quadbin-table-source.test.ts b/test/sources/quadbin-table-source.test.ts new file mode 100644 index 0000000..e16ea99 --- /dev/null +++ b/test/sources/quadbin-table-source.test.ts @@ -0,0 +1,74 @@ +import {WidgetTableSource, quadbinTableSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'quadbin-table-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('quadbinTableSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await quadbinTableSource({ + connectionName: 'carto_dw', + accessToken: '', + tableName: 'a.b.quadbin_table', + aggregationExp: 'SUM(population) as pop', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/table/); + expect(initURL).toMatch(/aggregationExp=SUM%28population%29\+as\+pop/); + expect(initURL).toMatch(/spatialDataColumn=quadbin/); + expect(initURL).toMatch(/spatialDataType=quadbin/); + expect(initURL).toMatch(/name=a.b.quadbin_table/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); + + test('widgetSource', async () => { + const {widgetSource} = await quadbinTableSource({ + accessToken: '', + connectionName: 'carto_dw', + tableName: 'my-table', + aggregationExp: 'COUNT (*)', + }); + + expect(widgetSource).toBeInstanceOf(WidgetTableSource); + }); +}); diff --git a/test/sources/quadbin-tileset-source.test.ts b/test/sources/quadbin-tileset-source.test.ts new file mode 100644 index 0000000..d1d2963 --- /dev/null +++ b/test/sources/quadbin-tileset-source.test.ts @@ -0,0 +1,59 @@ +import {quadbinTilesetSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'quadbin-tileset-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('quadbinTilesetSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await quadbinTilesetSource({ + connectionName: 'carto_dw', + accessToken: '', + tableName: 'a.b.quadbin_tileset', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/tileset/); + expect(initURL).toMatch(/name=a.b.quadbin_tileset/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); +}); diff --git a/test/sources/raster-source.test.ts b/test/sources/raster-source.test.ts new file mode 100644 index 0000000..d602bd0 --- /dev/null +++ b/test/sources/raster-source.test.ts @@ -0,0 +1,59 @@ +import {rasterSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'vector-tileset-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('rasterSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await rasterSource({ + connectionName: 'carto_dw', + accessToken: '', + tableName: 'a.b.raster_table', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/raster/); + expect(initURL).toMatch(/name=a\.b\.raster_table/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); +}); diff --git a/test/sources/vector-query-source.test.ts b/test/sources/vector-query-source.test.ts new file mode 100644 index 0000000..4e18e47 --- /dev/null +++ b/test/sources/vector-query-source.test.ts @@ -0,0 +1,78 @@ +import {WidgetQuerySource, vectorQuerySource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'vector-query-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('vectorQuerySource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await vectorQuerySource({ + connectionName: 'carto_dw', + accessToken: '', + sqlQuery: 'SELECT * FROM a.b.vector_table', + columns: ['a', 'b'], + spatialDataColumn: 'mygeom', + queryParameters: {type: 'Supermarket', minRevenue: 1000000}, + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/query/); + expect(initURL).toMatch(/q=SELECT\+\*\+FROM\+a\.b\.vector_table/); + expect(initURL).toMatch(/columns=a%2Cb/); + expect(initURL).toMatch(/spatialDataColumn=mygeom/); + expect(initURL).toMatch(/spatialDataType=geo/); + expect(initURL).toMatch( + /queryParameters=%7B%22type%22%3A%22Supermarket%22%2C%22minRevenue%22%3A1000000%7D/ + ); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); + + test('widgetSource', async () => { + const {widgetSource} = await vectorQuerySource({ + accessToken: '', + connectionName: 'carto_dw', + sqlQuery: 'SELECT *', + }); + + expect(widgetSource).toBeInstanceOf(WidgetQuerySource); + }); +}); diff --git a/test/sources/vector-table-source.test.ts b/test/sources/vector-table-source.test.ts new file mode 100644 index 0000000..45665aa --- /dev/null +++ b/test/sources/vector-table-source.test.ts @@ -0,0 +1,74 @@ +import {WidgetTableSource, vectorTableSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'vector-table-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('vectorTableSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await vectorTableSource({ + connectionName: 'carto_dw', + accessToken: '', + tableName: 'a.b.vector_table', + columns: ['a', 'b'], + spatialDataColumn: 'mygeom', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/table/); + expect(initURL).toMatch(/name=a\.b\.vector_table/); + expect(initURL).toMatch(/columns=a%2Cb/); + expect(initURL).toMatch(/spatialDataColumn=mygeom/); + expect(initURL).toMatch(/spatialDataType=geo/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); + + test('widgetSource', async () => { + const {widgetSource} = await vectorTableSource({ + accessToken: '', + connectionName: 'carto_dw', + tableName: 'my-table', + }); + + expect(widgetSource).toBeInstanceOf(WidgetTableSource); + }); +}); diff --git a/test/sources/vector-tileset-source.test.ts b/test/sources/vector-tileset-source.test.ts new file mode 100644 index 0000000..1050e01 --- /dev/null +++ b/test/sources/vector-tileset-source.test.ts @@ -0,0 +1,59 @@ +import {vectorTilesetSource} from '@carto/api-client'; +import {describe, afterEach, vi, test, expect, beforeEach} from 'vitest'; + +const CACHE = 'vector-tileset-source-test'; + +const INIT_RESPONSE = { + tilejson: {url: [`https://xyz.com?format=tilejson&cache=${CACHE}`]}, +}; + +const TILESET_RESPONSE = { + tilejson: '2.2.0', + tiles: ['https://xyz.com/{z}/{x}/{y}?formatTiles=binary'], + tilestats: {layers: []}, +}; + +describe('vectorTilesetSource', () => { + beforeEach(() => { + const mockFetch = vi + .fn() + .mockReturnValueOnce( + Promise.resolve({ok: true, json: () => Promise.resolve(INIT_RESPONSE)}) + ) + .mockReturnValueOnce( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(TILESET_RESPONSE), + }) + ); + + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => void vi.restoreAllMocks()); + + test('default', async () => { + const tilejson = await vectorTilesetSource({ + connectionName: 'carto_dw', + accessToken: '', + tableName: 'a.b.vector_tileset', + }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + + const [[initURL], [tilesetURL]] = vi.mocked(fetch).mock.calls; + + expect(initURL).toMatch(/v3\/maps\/carto_dw\/tileset/); + expect(initURL).toMatch(/name=a\.b\.vector_tileset/); + + expect(tilesetURL).toMatch( + /^https:\/\/xyz\.com\/\?format\=tilejson\&cache\=/ + ); + + expect(tilejson).toBeTruthy(); + expect(tilejson.tiles).toEqual([ + 'https://xyz.com/{z}/{x}/{y}?formatTiles=binary', + ]); + expect(tilejson.accessToken).toBe(''); + }); +}); diff --git a/test/sources/wrappers.test.ts b/test/sources/wrappers.test.ts deleted file mode 100644 index 1f165b1..0000000 --- a/test/sources/wrappers.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import {afterEach, expect, test, vi} from 'vitest'; -import { - vectorQuerySource, - vectorTableSource, - h3QuerySource, - h3TableSource, - quadbinQuerySource, - quadbinTableSource, - WidgetQuerySource, - WidgetTableSource, -} from '@carto/api-client'; - -const createMockFetchForTileJSON = () => - vi - .fn() - .mockResolvedValueOnce( - createMockFetchResponse({ - label: 'mapInit', - tilejson: {url: ['https://example.com']}, - }) - ) - .mockResolvedValueOnce( - createMockFetchResponse({ - label: 'tilejson', - tilejson: {url: ['https://example.com']}, - }) - ); - -const createMockFetchResponse = (data: unknown) => ({ - ok: true, - json: () => new Promise((resolve) => resolve(data)), -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -/****************************************************************************** - * VECTOR SOURCES - */ - -test('vectorQuerySource', async () => { - vi.stubGlobal('fetch', createMockFetchForTileJSON()); - - const {widgetSource} = await vectorQuerySource({ - accessToken: '', - connectionName: 'carto_dw', - sqlQuery: 'SELECT *', - }); - - expect(widgetSource).toBeInstanceOf(WidgetQuerySource); -}); - -test('vectorTableSource', async () => { - vi.stubGlobal('fetch', createMockFetchForTileJSON()); - - const {widgetSource} = await vectorTableSource({ - accessToken: '', - connectionName: 'carto_dw', - tableName: 'my-table', - }); - - expect(widgetSource).toBeInstanceOf(WidgetTableSource); -}); - -/****************************************************************************** - * H3 SOURCES - */ - -test('h3QuerySource', async () => { - vi.stubGlobal('fetch', createMockFetchForTileJSON()); - - const {widgetSource} = await h3QuerySource({ - accessToken: '', - connectionName: 'carto_dw', - sqlQuery: 'SELECT *', - aggregationExp: 'COUNT (*)', - }); - - expect(widgetSource).toBeInstanceOf(WidgetQuerySource); -}); - -test('h3TableSource', async () => { - vi.stubGlobal('fetch', createMockFetchForTileJSON()); - - const {widgetSource} = await h3TableSource({ - accessToken: '', - connectionName: 'carto_dw', - tableName: 'my-table', - aggregationExp: 'COUNT (*)', - }); - - expect(widgetSource).toBeInstanceOf(WidgetTableSource); -}); - -/****************************************************************************** - * QUADBIN SOURCES - */ - -test('quadbinQuerySource', async () => { - vi.stubGlobal('fetch', createMockFetchForTileJSON()); - - const {widgetSource} = await quadbinQuerySource({ - accessToken: '', - connectionName: 'carto_dw', - sqlQuery: 'SELECT *', - aggregationExp: 'COUNT (*)', - }); - - expect(widgetSource).toBeInstanceOf(WidgetQuerySource); -}); - -test('quadbinTableSource', async () => { - vi.stubGlobal('fetch', createMockFetchForTileJSON()); - - const {widgetSource} = await quadbinTableSource({ - accessToken: '', - connectionName: 'carto_dw', - tableName: 'my-table', - aggregationExp: 'COUNT (*)', - }); - - expect(widgetSource).toBeInstanceOf(WidgetTableSource); -}); diff --git a/test/sources/widget-base-source.test.ts b/test/widget-sources/widget-base-source.test.ts similarity index 100% rename from test/sources/widget-base-source.test.ts rename to test/widget-sources/widget-base-source.test.ts diff --git a/test/sources/widget-query-source.test.ts b/test/widget-sources/widget-query-source.test.ts similarity index 100% rename from test/sources/widget-query-source.test.ts rename to test/widget-sources/widget-query-source.test.ts diff --git a/test/sources/widget-table-source.test.ts b/test/widget-sources/widget-table-source.test.ts similarity index 100% rename from test/sources/widget-table-source.test.ts rename to test/widget-sources/widget-table-source.test.ts diff --git a/yarn.lock b/yarn.lock index d1e4038..7dccc4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1513,8 +1513,8 @@ __metadata: linkType: hard "@deck.gl/carto@npm:^9.0.30": - version: 9.0.32 - resolution: "@deck.gl/carto@npm:9.0.32" + version: 9.0.33 + resolution: "@deck.gl/carto@npm:9.0.33" dependencies: "@loaders.gl/gis": "npm:^4.2.0" "@loaders.gl/loader-utils": "npm:^4.2.0" @@ -1544,7 +1544,7 @@ __metadata: "@deck.gl/geo-layers": ^9.0.0 "@deck.gl/layers": ^9.0.0 "@loaders.gl/core": ^4.2.0 - checksum: 10c0/8c71f148d126c1ed2e5fc770fc7a85399a3d758ba6762c2c6cd2de49402fc3bcdd566d45c98ec315c4f5e4d65afdfe0222e852805d4665b08fd2cbece5166d4a + checksum: 10c0/97a50471fdf417919bf991fa40219f887582ddd71d2b4877146aa5ebd1e4cc0779021ed0e05cf0dcefc79194ef10d47c6df8a1b770726c917ebff9fa85e73864 languageName: node linkType: hard