diff --git a/.env-template b/.env-template index 9da3f0274..b84746494 100644 --- a/.env-template +++ b/.env-template @@ -13,7 +13,7 @@ POSTGRES_DB= POSTGRES_PORT= POSTGRES_SERVICE= # only when in a container -# For Express API Development +# For Express API Development & Express Keycloak FRONTEND_URL= BACKEND_URL= @@ -23,3 +23,10 @@ BACKEND_URL= SSO_CLIENT_ID= # Keycloak client_id SSO_CLIENT_SECRET= # Keycloak client_secret SSO_AUTH_SERVER_URL= # Keycloak auth URL, see example below. + +# CSS Keycloak API +CSS_API_CLIENT_ID= # Keycloak CSS API Service Account client_id +CSS_API_CLIENT_SECRET= # Keycloak CSS API Service Account client_secret + +# BC Geocoder +GEOCODER_KEY= diff --git a/express-api/Dockerfile b/express-api/Dockerfile index 9117d124f..1b06e2b18 100644 --- a/express-api/Dockerfile +++ b/express-api/Dockerfile @@ -2,7 +2,7 @@ # Base # ############################################# # Use an official Node.js runtime as a base image -FROM node:18.17.1-bullseye-slim as base +FROM node:21.6-bullseye-slim as base # Set the working directory in the container WORKDIR /express-api @@ -25,7 +25,7 @@ RUN npm run build ############################################# # Prod Build # ############################################# -FROM node:18.17.1-bullseye-slim as Prod +FROM node:21.6-bullseye-slim as Prod # Set the working directory to /express-api WORKDIR /express-api diff --git a/express-api/src/constants/index.ts b/express-api/src/constants/index.ts index eb2b3d681..47c111f17 100644 --- a/express-api/src/constants/index.ts +++ b/express-api/src/constants/index.ts @@ -1,8 +1,10 @@ import networking from '@/constants/networking'; import switches from '@/constants/switches'; +import urls from '@/constants/urls'; const constants = { ...networking, ...switches, + ...urls, }; export default constants; diff --git a/express-api/src/constants/urls.ts b/express-api/src/constants/urls.ts new file mode 100644 index 000000000..88aa170e2 --- /dev/null +++ b/express-api/src/constants/urls.ts @@ -0,0 +1,6 @@ +const urls = { + GEOCODER: { + HOSTURI: 'https://geocoder.api.gov.bc.ca', + }, +}; +export default urls; diff --git a/express-api/src/services/geocoder/geocoderService.ts b/express-api/src/services/geocoder/geocoderService.ts new file mode 100644 index 000000000..3a1ba460f --- /dev/null +++ b/express-api/src/services/geocoder/geocoderService.ts @@ -0,0 +1,75 @@ +import { IAddressModel } from '@/services/geocoder/interfaces/IAddressModel'; +import { IFeatureCollectionModel } from '@/services/geocoder/interfaces/IFeatureCollectionModel'; +import constants from '@/constants'; +import { IFeatureModel } from '@/services/geocoder/interfaces/IFeatureModel'; +import { getLongitude, getLatitude, getAddress1 } from './geocoderUtils'; +import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode'; +import { ISitePidsResponseModel } from './interfaces/ISitePidsResponseModel'; + +const mapFeatureToAddress = (feature: IFeatureModel): IAddressModel => { + return { + siteId: feature.properties.siteID, + fullAddress: feature.properties.fullAddress, + address1: getAddress1(feature.properties), + administrativeArea: feature.properties.localityName, + provinceCode: feature.properties.provinceCode, + longitude: getLongitude(feature.geometry), + latitude: getLatitude(feature.geometry), + score: feature.properties.score, + }; +}; + +/** + * @description Sends a request to Geocoder for addresses that match the specified 'address'. + * @param address String of searchable address. eg. "4000 Seymour St Victoria BC" + * @returns address information matching IAddressModel format + * @throws ErrorWithCode if the response is not 200 OK + */ +export const getSiteAddresses = async (address: string) => { + const url = new URL('/addresses.json', constants.GEOCODER.HOSTURI); + url.searchParams.append('addressString', address); + + const response = await fetch(url.toString(), { + headers: { + apiKey: process.env.GEOCODER__KEY, + }, + }); + + if (!response.ok) { + throw new ErrorWithCode('Failed to fetch data', response.status); + } + + const responseData = await response.json(); + const featureCollection: IFeatureCollectionModel = responseData; + const addressInformation: IAddressModel = mapFeatureToAddress(featureCollection.features[0]); + + return addressInformation; +}; + +/** + * @description Sends a request to Geocoder for all parcel identifiers (PIDs) associated with an individual site. + * @param siteId a unique identifier assigned to every site in B.C. + * @returns Valid 'siteId' values for an address + * @throws ErrorWithCode if result is not 200 OK + */ +export const getPids = async (siteId: string) => { + const url = new URL(`/parcels/pids/${siteId}.json`, constants.GEOCODER.HOSTURI); + const result = await fetch(url.toString(), { + headers: { + apiKey: process.env.GEOCODER_KEY, + }, + }); + + if (result.status != 200) { + throw new ErrorWithCode(result.statusText, result.status); + } + + const resultData = await result.json(); + const pidInformation: ISitePidsResponseModel = resultData; + return pidInformation; +}; + +export const GeocoderService = { + getSiteAddresses, + getPids, +}; diff --git a/express-api/src/services/geocoder/geocoderUtils.ts b/express-api/src/services/geocoder/geocoderUtils.ts new file mode 100644 index 000000000..71f02f506 --- /dev/null +++ b/express-api/src/services/geocoder/geocoderUtils.ts @@ -0,0 +1,77 @@ +/** + * geocoderUtils.ts - Utility functions for working with geocoding data + */ + +import { IPropertyModel } from '@/services/geocoder/interfaces/IPropertyModel'; +import { IGeometryModel } from '@/services/geocoder/interfaces/IGeometryModel'; +import { IFeatureModel } from '@/services/geocoder/interfaces/IFeatureModel'; +import { IAddressModel } from '@/services/geocoder/interfaces/IAddressModel'; + +/** + * Constructs address string based on property data. + * @param properties - The property data. + * @returns The constructed address string. + */ +export const getAddress1 = (properties: IPropertyModel): string => { + const address = []; + + if (properties.civicNumber) address.push(properties.civicNumber); + + if (properties.isStreetTypePrefix && properties.streetType) address.push(properties.streetType); + + if (properties.isStreetDirectionPrefix && properties.streetDirection) + address.push(properties.streetDirection); + + if (properties.streetName) address.push(properties.streetName); + + if (properties.streetQualifier) address.push(properties.streetQualifier); + + if (!properties.isStreetDirectionPrefix && properties.streetDirection) + address.push(properties.streetDirection); + + if (!properties.isStreetTypePrefix && properties.streetType) address.push(properties.streetType); + + return address.join(' '); +}; + +/** + * Retrieves latitude from geometry data. + * @param geometry - The geometry data. + * @returns The latitude value. + */ +export const getLatitude = (geometry: IGeometryModel): number => { + if (geometry.coordinates && geometry.coordinates.length === 2) { + return geometry.coordinates[1]; + } + return 0; +}; + +/** + * Retrieves longitude from geometry data. + * @param geometry - The geometry data. + * @returns The longitude value. + */ +export const getLongitude = (geometry: IGeometryModel): number => { + if (geometry.coordinates && geometry.coordinates.length === 2) { + return geometry.coordinates[0]; + } + return 0; +}; + +/** + * Maps feature data to address data. + * @param feature - The feature data. + * @returns The mapped address data. + */ +export const mapFeatureToAddress = (feature: IFeatureModel): IAddressModel => { + return { + siteId: feature.properties.siteID, + fullAddress: feature.properties.fullAddress, + address1: getAddress1(feature.properties), + administrativeArea: feature.properties.localityName, + provinceCode: feature.properties.provinceCode, + longitude: getLongitude(feature.geometry), + latitude: getLatitude(feature.geometry), + score: feature.properties.score, + }; +}; diff --git a/express-api/src/services/geocoder/interfaces/IAddressModel.ts b/express-api/src/services/geocoder/interfaces/IAddressModel.ts new file mode 100644 index 000000000..bd072b3b2 --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/IAddressModel.ts @@ -0,0 +1,10 @@ +export interface IAddressModel { + siteId: string; + fullAddress: string; + address1: string; + administrativeArea: string; + provinceCode: string; + latitude: number; + longitude: number; + score: number; +} diff --git a/express-api/src/services/geocoder/interfaces/ICrsModel.ts b/express-api/src/services/geocoder/interfaces/ICrsModel.ts new file mode 100644 index 000000000..63648753f --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/ICrsModel.ts @@ -0,0 +1,4 @@ +export interface ICrsModel { + type: string; + properties: { [key: string]: unknown }; +} diff --git a/express-api/src/services/geocoder/interfaces/IFaultModel.ts b/express-api/src/services/geocoder/interfaces/IFaultModel.ts new file mode 100644 index 000000000..4a87c21d1 --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/IFaultModel.ts @@ -0,0 +1,5 @@ +export interface IFaultModel { + element: string; + fault: string; + penalty: number; +} diff --git a/express-api/src/services/geocoder/interfaces/IFeatureCollectionModel.ts b/express-api/src/services/geocoder/interfaces/IFeatureCollectionModel.ts new file mode 100644 index 000000000..5ef1ca1d2 --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/IFeatureCollectionModel.ts @@ -0,0 +1,23 @@ +import { ICrsModel } from '@/services/geocoder/interfaces/ICrsModel'; +import { IFeatureModel } from '@/services/geocoder/interfaces/IFeatureModel'; + +export interface IFeatureCollectionModel { + type: string; + queryAddress: string; + searchTimestamp: string; + executionTime: number; + version: string; + baseDataDate: string; + crs: ICrsModel; + interpolation: string; + echo: string; + locationDescripture: string; + setback: number; + minScore: number; + maxResults: number; + disclaimer: string; + privacyStatement: string; + copyrightNotice: string; + copyrightLicense: string; + features: IFeatureModel[]; +} diff --git a/express-api/src/services/geocoder/interfaces/IFeatureModel.ts b/express-api/src/services/geocoder/interfaces/IFeatureModel.ts new file mode 100644 index 000000000..acb08bedb --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/IFeatureModel.ts @@ -0,0 +1,8 @@ +import { IGeometryModel } from '@/services/geocoder/interfaces/IGeometryModel'; +import { IPropertyModel } from '@/services/geocoder/interfaces/IPropertyModel'; + +export interface IFeatureModel { + type: string; + geometry: IGeometryModel; + properties: IPropertyModel; +} diff --git a/express-api/src/services/geocoder/interfaces/IGeometryModel.ts b/express-api/src/services/geocoder/interfaces/IGeometryModel.ts new file mode 100644 index 000000000..2d4b9ebc2 --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/IGeometryModel.ts @@ -0,0 +1,7 @@ +import { ICrsModel } from '@/services/geocoder/interfaces/ICrsModel'; + +export interface IGeometryModel { + type: string; + crs: ICrsModel; + coordinates: number[]; +} diff --git a/express-api/src/services/geocoder/interfaces/IPropertyModel.ts b/express-api/src/services/geocoder/interfaces/IPropertyModel.ts new file mode 100644 index 000000000..1572bb125 --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/IPropertyModel.ts @@ -0,0 +1,35 @@ +import { IFaultModel } from '@/services/geocoder/interfaces/IFaultModel'; + +export interface IPropertyModel { + fullAddress: string; + score: number; + matchPrecision: string; + precisionPoints: number; + faults: IFaultModel[]; + siteName: string; + unitDesignator: string; + unitNumber: string; + unitNumberSuffix: string; + civicNumber: string; + civicNumberSuffix: string; + streetName: string; + streetType: string; + isStreetTypePrefix: boolean; + streetDirection: string; + isStreetDirectionPrefix: boolean; + streetQualifier: string; + localityName: string; + localityType: string; + electoralArea: string; + provinceCode: string; + locationPositionalAccuracy: string; + locationDescriptor: string; + siteID: string; + blockID: string; + fullSiteDescriptor: string; + accessNotes: string; + siteStatus: string; + siteRetireDate: string; + changeDate: string; + isOfficial: string; +} diff --git a/express-api/src/services/geocoder/interfaces/ISitePidsResponseModel.ts b/express-api/src/services/geocoder/interfaces/ISitePidsResponseModel.ts new file mode 100644 index 000000000..0125e41e8 --- /dev/null +++ b/express-api/src/services/geocoder/interfaces/ISitePidsResponseModel.ts @@ -0,0 +1,4 @@ +export interface ISitePidsResponseModel { + siteID: string; + pids: string; +} diff --git a/express-api/tests/unit/services/geocoder/geocoderService.test.ts b/express-api/tests/unit/services/geocoder/geocoderService.test.ts new file mode 100644 index 000000000..f174220e7 --- /dev/null +++ b/express-api/tests/unit/services/geocoder/geocoderService.test.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { GeocoderService } from '@/services/geocoder/geocoderService'; + +const mockJson = { + type: 'FeatureCollection', + queryAddress: '4000 seymour pl victoria, bc', + searchTimestamp: '2024-02-16 07:55:18', + executionTime: 3.411, + version: '4.3.0-SNAPSHOT', + baseDataDate: '2024-01-09', + crs: { + type: 'EPSG', + properties: { + code: 4326, + }, + }, + interpolation: 'adaptive', + echo: 'true', + locationDescriptor: 'any', + setBack: 0, + minScore: 0, + maxResults: 1, + disclaimer: 'https://www2.gov.bc.ca/gov/content?id=79F93E018712422FBC8E674A67A70535', + privacyStatement: 'https://www2.gov.bc.ca/gov/content?id=9E890E16955E4FF4BF3B0E07B4722932', + copyrightNotice: 'Copyright © 2024 Province of British Columbia', + copyrightLicense: 'https://www2.gov.bc.ca/gov/content?id=A519A56BC2BF44E4A008B33FCF527F61', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + crs: { + type: 'EPSG', + properties: { + code: 4326, + }, + }, + coordinates: [-123.3692171, 48.4533429], + }, + properties: { + fullAddress: '4000 Seymour Pl, Saanich, BC', + score: 96, + matchPrecision: 'CIVIC_NUMBER', + precisionPoints: 100, + faults: [ + { + value: 'VICTORIA', + element: 'LOCALITY', + fault: 'isAlias', + penalty: 4, + }, + ], + siteName: '', + unitDesignator: '', + unitNumber: '', + unitNumberSuffix: '', + civicNumber: 4000, + civicNumberSuffix: '', + streetName: 'Seymour', + streetType: 'Pl', + isStreetTypePrefix: 'false', + streetDirection: '', + isStreetDirectionPrefix: '', + streetQualifier: '', + localityName: 'Saanich', + localityType: 'District Municipality', + electoralArea: '', + provinceCode: 'BC', + locationPositionalAccuracy: 'high', + locationDescriptor: 'parcelPoint', + siteID: 'eccd759a-8476-46b0-af5d-e1c071f8e78e', + blockID: 512804, + fullSiteDescriptor: '', + accessNotes: '', + siteStatus: 'active', + siteRetireDate: '9999-12-31', + changeDate: '2024-01-10', + isOfficial: 'true', + }, + }, + ], +}; +const stringjson = JSON.stringify(mockJson); + +describe('UNIT - Geoserver services', () => { + describe('getSiteAddresses', () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(new Response(stringjson))); + + it('should get an address from Geocoder service.', async () => { + const address = await GeocoderService.getSiteAddresses('4000 Seymour pl BC'); + expect(typeof address === 'object' && !Array.isArray(address) && address !== null).toBe(true); + expect(address.siteId != '').toBe(true); + }); + it('should return an error when service is unreachable.', async () => { + fetchMock.mockImplementationOnce(() => Promise.resolve(new Response('', { status: 500 }))); + expect(async () => { + await GeocoderService.getSiteAddresses(''); + }).rejects.toThrow(); + }); + }); + + describe('getPids', () => { + const pidData = { + siteID: 'eccd759a-8476-46b0-af5d-e1c071f8e78e', + pids: '000382345', + }; + const stringPids = JSON.stringify(pidData); + + it('should get a list of PIDs connected to the site address.', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementationOnce(() => Promise.resolve(new Response(stringPids))); + const pids = await GeocoderService.getPids('eccd759a-8476-46b0-af5d-e1c071f8e78e'); + expect(typeof pids === 'object' && !Array.isArray(pids) && pids !== null).toBe(true); + expect(typeof pids.pids === 'string' && pids.pids === '000382345').toBe(true); + }); + + it('should thow an error if geocoder service is down.', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementationOnce(() => Promise.resolve(new Response('', { status: 500 }))); + expect(async () => { + await GeocoderService.getPids(''); + }).rejects.toThrow(); + }); + }); +});