Skip to content

Commit

Permalink
GeocoderWidget support for LDS-based gecoding.
Browse files Browse the repository at this point in the history
  • Loading branch information
zbigg committed Apr 28, 2022
1 parent 5868b6e commit 54697b3
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 58 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Not released

- GeocoderWidget support for LDS queries [#387](https://github.com/CartoDB/carto-react/pull/387)

## 1.3

### 1.3.0-alpha.6 (2022-04-27)
Expand Down
49 changes: 49 additions & 0 deletions packages/react-api/__tests__/api/lds.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { API_VERSIONS } from '@deck.gl/carto';
import { ldsGeocode } from '../../src/api/lds';

const sampleCredentialsV3 = {
apiVersion: API_VERSIONS.V3,
accessToken: 'thisIsTheTestToken',
apiBaseUrl: 'https://api.com/'
};

const someCoordinates = {
latitude: 42.360278,
longitude: -71.057778
};

describe('lds', () => {
describe('ldsDecode', () => {
test('should send proper requests ', async () => {
const fetchMock = (global.fetch = jest.fn().mockImplementation(async () => {
return {
ok: true,
json: async () => [{ value: [someCoordinates] }]
};
}));

const abortController = new AbortController();
expect(
await ldsGeocode({
credentials: sampleCredentialsV3,
address: 'boston',
country: 'US',
limit: 4,
opts: {
abortController: abortController
}
})
).toEqual([someCoordinates]);

expect(fetchMock).toBeCalledWith(
'https://api.com//v3/lds/geocoding/geocode?address=boston&country=US&limit=4',
{
headers: {
Authorization: `Bearer ${sampleCredentialsV3.accessToken}`
},
signal: abortController.signal
}
);
});
});
});
47 changes: 46 additions & 1 deletion packages/react-api/src/api/SQL.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { encodeParameter, getRequest, postRequest } from '@carto/react-core';
import { REQUEST_GET_MAX_URL_LENGTH } from '@carto/react-core';
import { API_VERSIONS } from '@deck.gl/carto';

import { dealWithApiError, generateApiUrl } from './common';
import { dealWithApiError } from './common';

const CLIENT = 'carto-react';
const DEFAULT_USER_COMPONENT_IN_URL = '{user}';

/**
* Executes a SQL query
Expand Down Expand Up @@ -100,3 +101,47 @@ function createRequest({ credentials, connection, query, opts = {} }) {
});
return postRequest(postUrl, rawParams, requestOpts);
}

/**
* Generate a valid API url for a request
*/
export function generateApiUrl({ credentials, connection, parameters }) {
const { apiVersion = API_VERSIONS.V2, apiBaseUrl } = credentials;

let url;
switch (apiVersion) {
case API_VERSIONS.V1:
case API_VERSIONS.V2:
url = `${sqlApiUrl(credentials)}api/v2/sql`;
break;

case API_VERSIONS.V3:
url = `${apiBaseUrl}/v3/sql/${connection}/query`;
break;

default:
throw new Error(`Unknown apiVersion ${apiVersion}`);
}

if (!parameters) {
return url;
}

return `${url}?${parameters.join('&')}`;
}

/**
* Prepare a url valid for the specified user, from the serverUrlTemplate
*/
function sqlApiUrl(credentials) {
let url = credentials.serverUrlTemplate.replace(
DEFAULT_USER_COMPONENT_IN_URL,
credentials.username
);

if (!url.endsWith('/')) {
url += '/';
}

return url;
}
48 changes: 0 additions & 48 deletions packages/react-api/src/api/common.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import { API_VERSIONS } from '@deck.gl/carto';

const DEFAULT_USER_COMPONENT_IN_URL = '{user}';

/**
* Return more descriptive error from API
*/
Expand All @@ -15,47 +11,3 @@ export function dealWithApiError({ credentials, response, data }) {
throw new Error(`${JSON.stringify(data.hint || data.error?.[0])}`);
}
}

/**
* Generate a valid API url for a request
*/
export function generateApiUrl({ credentials, connection, parameters }) {
const { apiVersion = API_VERSIONS.V2, apiBaseUrl } = credentials;

let url;
switch (apiVersion) {
case API_VERSIONS.V1:
case API_VERSIONS.V2:
url = `${sqlApiUrl(credentials)}api/v2/sql`;
break;

case API_VERSIONS.V3:
url = `${apiBaseUrl}/v3/sql/${connection}/query`;
break;

default:
throw new Error(`Unknown apiVersion ${apiVersion}`);
}

if (!parameters) {
return url;
}

return `${url}?${parameters.join('&')}`;
}

/**
* Prepare a url valid for the specified user, from the serverUrlTemplate
*/
function sqlApiUrl(credentials) {
let url = credentials.serverUrlTemplate.replace(
DEFAULT_USER_COMPONENT_IN_URL,
credentials.username
);

if (!url.endsWith('/')) {
url += '/';
}

return url;
}
60 changes: 60 additions & 0 deletions packages/react-api/src/api/lds.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { dealWithApiError } from './common';

/**
* Execute a LDS geocoding service geocode request.
*
* @param { object } props
* @param { string } props.address - searched address to be executed
* @param { string= } props.country - optional, limit search scope to country as ISO-3166 alpha-2 code, example, ES, DE
* @param { number= } props.limit - optional, limit of ansewers
* @param { Object } props.credentials - CARTO user credentials
* @param { string } props.credentials.accessToken - CARTO 3 access token
* @param { string } props.credentials.apiBaseUrl - CARTO 3 api server URL
* @param { Object= } props.opts - Additional options for the HTTP request
*/
export async function ldsGeocode({ credentials, address, country, limit, opts }) {
let response;

if (!credentials || !credentials.apiBaseUrl || !credentials.accessToken) {
throw new Error('ldsGeocode: Missing or bad credentials provided');
}
if (!address) {
throw new Error('ldsGeocode: No address provided');
}

const url = new URL(`${credentials.apiBaseUrl}/v3/lds/geocoding/geocode`);
url.searchParams.set('address', address);
if (country) {
url.searchParams.set('country', country);
}
if (limit) {
url.searchParams.set('limit', String(limit));
}

const { abortController, ...otherOptions } = opts || {};

try {
response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${credentials.accessToken}`
},
signal: abortController?.signal,
...otherOptions
});
} catch (error) {
if (error.name === 'AbortError') throw error;

throw new Error(`Failed to connect to LDS API: ${error}`);
}

let data = await response.json();

if (Array.isArray(data)) {
data = data[0];
}
if (!response.ok) {
dealWithApiError({ credentials, response, data });
}

return data.value;
}
1 change: 1 addition & 0 deletions packages/react-api/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { executeSQL } from './api/SQL';
export { ldsGeocode } from './api/lds';

export { default as useCartoLayerProps } from './hooks/useCartoLayerProps';

Expand Down
1 change: 1 addition & 0 deletions packages/react-api/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { executeSQL } from './api/SQL';
export { ldsGeocode } from './api/lds';

export { default as useCartoLayerProps } from './hooks/useCartoLayerProps';

Expand Down
4 changes: 2 additions & 2 deletions packages/react-redux/src/slices/oauthSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ export const setTokenAndUserInfoAsync = createAsyncThunk(
export const logout = () => ({ type: 'oauth/logout', payload: {} });

// Get the credentials, from curren token & userInfo
const selectToken = (state) => state.oauth.token;
const selectUserInfo = (state) => state.oauth.userInfo;
const selectToken = (state) => state.oauth?.token;
const selectUserInfo = (state) => state.oauth?.userInfo;

/**
* Selector to fetch the current OAuth credentials from the store
Expand Down
68 changes: 68 additions & 0 deletions packages/react-widgets/__tests__/models/GeocodingModel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { geocodeStreetPoint } from '../../src/models/GeocodingModel';
import { API_VERSIONS } from '@deck.gl/carto';
import { ldsGeocode, executeSQL } from '@carto/react-api/';
// import * as cartoReactApi from '@carto/react-api';

const sampleCredentialsV2 = {
apiVersion: API_VERSIONS.V2
};
const sampleCredentialsV3 = {
apiVersion: API_VERSIONS.V3,
accessToken: 'token',
apiBaseUrl: 'https://api.com/'
};

// const executeSqlMock = jest.spyOn(cartoReactApi, 'executeSQL');
// const ldsGeocodeMock = jest.spyOn(cartoReactApi, 'ldsGeocode');

const bostonCoordinates = {
latitude: 42.360278,
longitude: -71.057778
};

jest.mock('@carto/react-api', () => ({
executeSQL: jest.fn().mockImplementation(async () => global.executeSqlMockResult),
ldsGeocode: jest.fn().mockImplementation(async () => global.ldsGeocodeMockResult)
}));

describe('geocodeStreetPoint', () => {
test('executes correct SQL for V2 credentials', async () => {
global.executeSqlMockResult = [
{
geometry: JSON.stringify({
coordinates: [bostonCoordinates.longitude, bostonCoordinates.latitude]
})
}
];

const result = await geocodeStreetPoint(sampleCredentialsV2, {
searchText: 'boston',
country: 'US'
});

expect(result).toEqual(bostonCoordinates);

expect(executeSQL).toHaveBeenCalledWith({
credentials: sampleCredentialsV2,
query:
"SELECT ST_AsGeoJSON(cdb_geocode_street_point('boston', '', '', 'US')) AS geometry",
opts: {}
});
});

test('uses ldsDecode for V3 credentials', async () => {
global.ldsGeocodeMockResult = [bostonCoordinates];
const result = await geocodeStreetPoint(sampleCredentialsV3, {
searchText: 'boston'
});

expect(result).toEqual(bostonCoordinates);

expect(ldsGeocode).toHaveBeenCalledWith({
credentials: sampleCredentialsV3,
address: 'boston',
limit: 1,
opts: {}
});
});
});
51 changes: 46 additions & 5 deletions packages/react-widgets/src/models/GeocodingModel.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,56 @@
// Geocoding / Data Services https://carto.com/developers/data-services-api/reference/

import { executeSQL } from '@carto/react-api';
import { executeSQL, ldsGeocode } from '@carto/react-api';
import { API_VERSIONS } from '@deck.gl/carto';

/**
* Street-Level Geocoder
* @param {*} credentials
* @param {*} { searchText, city, state, country } all optional but searchText
* Street-Level Geocoder.
*
* Geocode street given as address and optionally county into geo location (latitude, longitute).
* Only first result is returned.
*
* Method:
* * `sql` default for Carto v2 credentials, execute `ST_AsGeoJSON` against connection specified in credentials
* * `lds` default for Carto v3 credentials, execute query against Carto Location Data Services
*
* @param {object} credentials
* @param { object } props
* @param { string= } props.method - method, either `sql` or `lds`
* @param { string } props.searchText - searched address to be executed
* @param { string= } props.country - optional, limit search scope to country as ISO-3166 alpha-2 code, example, ES, DE
* @param { string= } props.city - optional, limit search scope to state
* @param { string= } props.state - optional, limit search scope to city
* @param { number= } props.limit - optional, limit of ansewers
*/
export const geocodeStreetPoint = async (
export const geocodeStreetPoint = async (credentials, props, opts = {}) => {
const { searchText, city, state, country } = props;
if (
props.method === 'lds' ||
(!props.method && credentials.apiVersion === API_VERSIONS.V3)
) {
return geocodeStreetPointLds(credentials, { searchText, country }, opts);
}

return geocodeStreetPointSql(credentials, { searchText, city, state, country }, opts);
};

export const geocodeStreetPointLds = async (
credentials,
{ searchText, country },
opts = {}
) => {
const results = await ldsGeocode({
credentials,
address: searchText,
country,
limit: 1,
opts
});

return results?.[0];
};

export const geocodeStreetPointSql = async (
credentials,
{ searchText, city, state, country },
opts = {}
Expand Down
Loading

0 comments on commit 54697b3

Please sign in to comment.