Skip to content

Commit

Permalink
GeocoderWidget support for LDS-based gecoding for CARTO 3 (#387)
Browse files Browse the repository at this point in the history
  • Loading branch information
zbigg authored Apr 28, 2022
1 parent 5868b6e commit b429a9a
Show file tree
Hide file tree
Showing 15 changed files with 380 additions and 145 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 in CARTO 3 [#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;
}
50 changes: 1 addition & 49 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 @@ -12,50 +8,6 @@ export function dealWithApiError({ credentials, response, data }) {
case 403:
throw new Error('Forbidden access to the requested data');
default:
throw new Error(`${JSON.stringify(data.hint || data.error?.[0])}`);
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;
}
58 changes: 58 additions & 0 deletions packages/react-api/src/api/lds.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 }) {
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 || {};

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

throw new Error(`Failed to connect to LDS API: ${error}`);
}
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
2 changes: 1 addition & 1 deletion packages/react-redux/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export type InitialCartoState = InitialCarto2State | InitialCarto3State;

export type CartoState = {
viewport: Viewport | undefined,
geocoderResult: object | null,
geocoderResult: Record<string,any> | null,
error: null, // TODO: remove from state?
layers: { [key: string]: string },
dataSources: { [key: string]: string },
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/';

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

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', () => {
afterEach(() => {
delete global.executeSqlMockResult;
delete global.ldsGeocodeMockResult;
});
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: {}
});
});
});
Loading

0 comments on commit b429a9a

Please sign in to comment.