Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1 +1 @@
AQICN_TOKEN=
AQICN_TOKEN=6bb4237574756ba29f05cea553bd22576596c11e
4 changes: 1 addition & 3 deletions packages/dataproviders/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
"@shootismoke/convert": "^0.9.1",
"axios": "^0.27.2",
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7",
"fp-ts": "^2.12.3",
"io-ts": "^2.2.18"
"date-fns-tz": "^1.3.7"
},
"devDependencies": {
"dotenv": "^16.0.3"
Expand Down
1 change: 0 additions & 1 deletion packages/dataproviders/src/fp/index.ts

This file was deleted.

8 changes: 5 additions & 3 deletions packages/dataproviders/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
export * from './providers/types';
export { AqicnOptions } from './providers/aqicn/fetchBy';
export { OpenAQOptions } from './providers/openaq/fetchBy';
export * from './promise';
export * from './types';
export {
AllProviders,
ACCURATE_RADIUS,
getDominantPol,
getCountryFromCode,
stationName,
} from './util';
export * from './util/fp';
export * from './util/openaq';

export * from './providers/aqicn/aqicn';
export * from './providers/openaq/openaq';
export * from './providers/waqi/waqi';
50 changes: 0 additions & 50 deletions packages/dataproviders/src/promise.ts

This file was deleted.

6 changes: 2 additions & 4 deletions packages/dataproviders/src/providers/aqicn/aqicn.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import * as E from 'fp-ts/lib/Either';

import { aqicn } from './aqicn';

describe('aqicn', () => {
it('should throw without token', async () => {
expect(await aqicn.fetchByStation('foo')()).toEqual(
E.left(new Error('AqiCN requires a token'))
await expect(aqicn.fetchByStation('foo')).rejects.toThrowError(
new Error('AqiCN requires a token')
);
});
});
9 changes: 4 additions & 5 deletions packages/dataproviders/src/providers/aqicn/aqicn.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { ProviderFP } from '../types';
import { Provider } from '../types';
import { AqicnOptions, fetchByGps, fetchByStation } from './fetchBy';
import { normalize } from './normalize';
import { AqicnStaton } from './validation';
import { AqicnData } from './validation';

/**
* @see https://aqicn.org
*/
export const aqicn: ProviderFP<AqicnStaton, AqicnStaton, AqicnOptions> = {
export const aqicn: Provider<AqicnData, AqicnOptions> = {
fetchByGps,
fetchByStation,
id: 'aqicn',
name: 'AQI CN',
normalizeByGps: normalize,
normalizeByStation: normalize,
normalize,
};
73 changes: 25 additions & 48 deletions packages/dataproviders/src/providers/aqicn/fetchBy.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import { pipe } from 'fp-ts/lib/pipeable';
import * as TE from 'fp-ts/lib/TaskEither';

import { LatLng } from '../../types';
import { fetchAndDecode } from '../../util';
import { AqicnStaton, ByStation, ByStationCodec } from './validation';
import { fetchAndDecode } from '../../util/fetch';
import { AqicnData, AqicnResponse } from './validation';

/**
* Check if the response we get from aqicn is `{"status": "error", "msg": "..."}`,
* if yes, return an error.
*/
function checkError({
status,
data,
msg,
}: ByStation): TE.TaskEither<Error, AqicnStaton> {
return status === 'ok'
? TE.right(data)
: TE.left(new Error(msg || (data as string)));
function checkError({ status, data, msg }: AqicnResponse): AqicnData {
if (status === 'ok' && typeof data === 'object') {
return data;
} else {
throw new Error(msg || (data as string));
}
}

export interface AqicnOptions {
Expand All @@ -27,18 +22,6 @@ export interface AqicnOptions {
token: string;
}

/**
* Check that a token has been correctly passed
*
* @param options - Options to pass to aqicn
*/
function checkToken(options?: AqicnOptions): TE.TaskEither<Error, undefined> {
if (!options || !options.token) {
return TE.left(new Error('AqiCN requires a token'));
}
return TE.right(undefined);
}

/**
* Fetch the closest station to the user's current position
*
Expand All @@ -47,38 +30,32 @@ function checkToken(options?: AqicnOptions): TE.TaskEither<Error, undefined> {
export function fetchByGps(
gps: LatLng,
options: AqicnOptions
): TE.TaskEither<Error, AqicnStaton> {
): Promise<AqicnData> {
if (!options || !options.token) {
throw new Error('AqiCN requires a token');
}

const { latitude, longitude } = gps;

return pipe(
checkToken(options),
TE.chain(() =>
fetchAndDecode(
`https://api.waqi.info/feed/geo:${latitude};${longitude}/?token=${options.token}`,
ByStationCodec
)
),
TE.chain(checkError)
);
return fetchAndDecode<AqicnResponse>(
`https://api.waqi.info/feed/geo:${latitude};${longitude}/?token=${options.token}`
).then(checkError);
}

/**
* Fetch data by station
*
* @param stationId - The station ID to search
*/
export function fetchByStation(
export async function fetchByStation(
stationId: string,
options: AqicnOptions
): TE.TaskEither<Error, AqicnStaton> {
return pipe(
checkToken(options),
TE.chain(() =>
fetchAndDecode(
`https://api.waqi.info/feed/@${stationId}/?token=${options.token}`,
ByStationCodec
)
),
TE.chain(checkError)
);
): Promise<AqicnData> {
if (!options || !options.token) {
throw new Error('AqiCN requires a token');
}

return fetchAndDecode<AqicnResponse>(
`https://api.waqi.info/feed/@${stationId}/?token=${options.token}`
).then(checkError);
}
123 changes: 53 additions & 70 deletions packages/dataproviders/src/providers/aqicn/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ import {
usaEpa,
} from '@shootismoke/convert';
import { format, utcToZonedTime } from 'date-fns-tz';
import * as E from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';

import { OpenAQResults } from '../../types';
import { getCountryCode, providerError } from '../../util';
import sanitized from './sanitized.json';
import type { AqicnStaton } from './validation';
import type { AqicnData } from './validation';

/**
* Sanitize the country we get here from aqicn. For example, for China, the
Expand All @@ -32,28 +30,20 @@ export function sanitizeCountry(input: string): string {
}

/**
* Normalize aqicn byGps data
* Normalize aqicn byGps data. Throws an error if the data cannot be normalized.
*
* @param data - The data to normalize
*/
export function normalize(data: AqicnStaton): E.Either<Error, OpenAQResults> {
if (!data || typeof data === 'string') {
return E.left(
providerError('aqicn', `Cannot normalized ${data || 'undefined'}`)
);
}

export function normalize(data: AqicnData): OpenAQResults {
const stationId = `aqicn|${data.idx}`;

// Sometimes we don't get geo
if (!data.city.geo) {
return E.left(
providerError(
'aqicn',
`Cannot normalize station ${stationId}, no city: ${JSON.stringify(
data
)}`
)
throw providerError(
'aqicn',
`Cannot normalize station ${stationId}, no city: ${JSON.stringify(
data
)}`
);
}

Expand All @@ -63,27 +53,21 @@ export function normalize(data: AqicnStaton): E.Either<Error, OpenAQResults> {
usaEpa.pollutants.includes(pol as Pollutant)
);
if (!pollutants.length) {
return E.left(
providerError(
'aqicn',
`Cannot normalize station ${stationId}, no pollutants currently tracked: ${JSON.stringify(
data
)}`
)
throw providerError(
'aqicn',
`Cannot normalize station ${stationId}, no pollutants currently tracked: ${JSON.stringify(
data
)}`
);
}
// We now need to get the country from AQICN response. The only place I found
// is city.url...
// Example: https://aqicn.org/city/france/lorraine/thionville-nord/garche/
const AQICN_DOMAIN = 'aqicn.org/city/';
if (!data.city.url || !data.city.url.includes(AQICN_DOMAIN)) {
return E.left(
providerError(
'aqicn',
`Cannot extract country, got city.url: ${
data.city.url as string
}`
)
throw providerError(
'aqicn',
`Cannot extract country, got city.url: ${data.city.url as string}`
);
}
const countryRaw = sanitizeCountry(
Expand All @@ -99,44 +83,43 @@ export function normalize(data: AqicnStaton): E.Either<Error, OpenAQResults> {
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx"
);

return pipe(
getCountryCode(countryRaw),
E.fromOption(() =>
providerError('aqicn', `Cannot get code from country ${countryRaw}`)
),
E.map(
(country) =>
pollutants.map(([pol, { v }]) => {
const pollutant = pol as Pollutant;
const countryCode = getCountryCode(countryRaw);
if (!countryCode) {
throw providerError(
'aqicn',
`Cannot get code from country ${countryRaw}`
);
}

if (!data.city.geo) {
throw new Error(
'We returned TE.left if data.city.geo was not defined. qed.'
);
}
return pollutants.map(([pol, { v }]) => {
const pollutant = pol as Pollutant;

return {
attribution: data.attributions,
averagingPeriod: {
unit: 'day',
value: 1,
},
city: data.city.name,
coordinates: {
latitude: +data.city.geo[0],
longitude: +data.city.geo[1],
},
country,
date: { local, utc },
location: stationId,
isMobile: false,
parameter: pollutant,
sourceName: 'aqicn',
entity: 'other',
value: convert(pollutant, 'usaEpa', ugm3, v),
unit: getPollutantMeta(pollutant).preferredUnit,
};
}) as OpenAQResults
)
);
if (!data.city.geo) {
throw new Error(
'We returned TE.left if data.city.geo was not defined. qed.'
);
}

return {
attribution: data.attributions,
averagingPeriod: {
unit: 'day',
value: 1,
},
city: data.city.name,
coordinates: {
latitude: +data.city.geo[0],
longitude: +data.city.geo[1],
},
country: countryCode,
date: { local, utc },
location: stationId,
isMobile: false,
parameter: pollutant,
sourceName: 'aqicn',
entity: 'other',
value: convert(pollutant, 'usaEpa', ugm3, v),
unit: getPollutantMeta(pollutant).preferredUnit,
};
}) as OpenAQResults;
}
Loading