diff --git a/emanifest-js/README.md b/emanifest-js/README.md index e4ebe21e..76abae06 100644 --- a/emanifest-js/README.md +++ b/emanifest-js/README.md @@ -5,10 +5,11 @@ ## Intro The [emanifest npm package](https://www.npmjs.com/package/emanifest) is an API client library. -It simplifies the task of using the RCRAInfo/e-Manifest web services by abstracting the +It simplifies the task of using the RCRAInfo/e-Manifest web services by abstracting the authentication process, providing developer friendly API, and exporting TypeScript types. -It's built on top of the [Axios](https://axios-http.com/) library, and can be used in both Node and browser -runtime environments (EPA has discussed making some public API available that do not need authentication in the near future). +It's built on top of the [Axios](https://axios-http.com/) library, and can be used in both Node and browser +runtime environments (EPA has discussed making some public API available that do not need authentication in the near +future). For additional information about e-Manifest, check out the below links @@ -22,71 +23,100 @@ For a python alternative see the [emanifest package on PyPI](https://pypi.org/pr ## Installation -```bash - $ npm install emanifest +```bash + $ npm install emanifest or $ yarn add emanifest ``` + ## Basic Usage The primary export of the `emanifest` package is the `newClient` function. -A constructor that accepts a configuration object and returns a new `RcraClient` -instance. +A constructor that accepts a configuration object and returns a new `RcraClient` +instance. ```typescript import { AxiosResponse } from 'axios'; -import { newClient, RCRAINFO_PREPROD, AuthResponse } from 'emanifest' +import { newClient, RCRAINFO_PREPROD, AuthResponse } from 'emanifest'; // The newClient accepts an instance of the RcraClientConfig which follows this interface // interface RcraClientConfig { // apiBaseURL?: RcrainfoEnv; // default: RCRAINFO_PREPROD // apiID?: string; // apiKey?: string; -// authAuth?: Boolean; // default: false +// autoAuth?: Boolean; // default: false +// validateInput?: Boolean; // default: false // } -const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, apiID: 'my_api_id', apiKey: 'my_api_key' }) -const authResponse: AxiosResponse = await rcrainfo.authenticate() -console.log(authResponse.data) - +const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, apiID: 'my_api_id', apiKey: 'my_api_key' }); +const authResponse: AxiosResponse = await rcrainfo.authenticate(); +console.log(authResponse.data); ``` -### Other Exports +### Other Exports -The emanifest package also exports the `RCRAINFO_PREPROD` and `RCRAINFO_PROD` constants which can be used to set +The emanifest package also exports the `RCRAINFO_PREPROD` and `RCRAINFO_PROD` constants which can be used to set the `apiBaseURL` property of the `RcraClientConfig` object. ### Types -The emanifest package exports a types/interfaces that can be used in statically typed projects. -The types follow the OpenAPI schema definitions that can be found in the [USEPA/e-manifest schema directory](https://github.com/USEPA/e-manifest/tree/master/Services-Information/Schema) +The emanifest package exports types/interfaces that can be used in statically typed projects. +The types follow the OpenAPI schema definitions that can be found in +the [USEPA/e-manifest schema directory](https://github.com/USEPA/e-manifest/tree/master/Services-Information/Schema) however, some names have been modified for clarity (for example `RcraCode` instead of simply `Code`). - ## Auto-Authentication The `emanifest` package can be explicitly configured to automatically authenticate when needed. ```typescript import { AxiosResponse } from 'axios'; -import { newClient, RCRAINFO_PREPROD, AuthResponse, RcraClientClass, RcraCode } from 'emanifest' +import { newClient, RCRAINFO_PREPROD, AuthResponse, RcraClientClass, RcraCode } from 'emanifest'; const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, apiID: 'my_api_id', apiKey: 'my_api_key', - autoAuth: true // Set the RcraClient to automatically authenticate as needed -}) + autoAuth: true, // Set the RcraClient to automatically authenticate as needed +}); // the authenticate method is NOT explicitly called -const resp: AxtiosResponse = await rcrainfo.getStateWasteCodes('VA') +const resp: AxtiosResponse = await rcrainfo.getStateWasteCodes('VA'); + +console.log(resp.data); // [ { code: 'BCRUSH', description: 'Bulb or Lamp Crusher' } ] + +console.log(rcrainfo.isAuthenticated()); // true +``` + +## Input Validation -console.log(resp.data) // [ { code: 'BCRUSH', description: 'Bulb or Lamp Crusher' } ] +The `emanifest` package can be explicitly configured to provide some simple input validation. This behavior is disabled +by default. It must be explicitly enabled by setting the `validateInput` property of the `RcraClientConfig` on +initiation. -console.log(rcrainfo.isAuthenticated()) // true +1. `siteID` must be a string of length 12 +2. `stateCode` must be a string of length 2 +3. `siteType` must be one of the following: ['Generator', 'Tsdf', 'Transporter', 'Rejection_AlternateTsdf'] +4. `dateType` (a search parameter) must be one of the + following: ['CertifiedDate', 'ReceivedDate', 'ShippedDate', 'UpdatedDate'] +5. `manifestTrackingNumber` must be a string of length 12 +Upon validation failure, the `RcraClient` will throw an error which can be caught and handled the same as an error +received from the axios library. + +```typescript +import { newClient } from 'emanifest'; + +const rcrainfo = newClient({ validateInput: true }); + +try { + const resp = await rcrainfo.getSite('VA12345'); +} catch (err) { + console.log(err.message); // "siteID must be a string of length 12" +} ``` +``` ## Disclaimer @@ -99,4 +129,5 @@ processes, or services by service mark, trademark, manufacturer, or otherwise, does not constitute or imply their endorsement, recommendation or favoring by EPA. The EPA seal and logo shall not be used in any manner to imply endorsement of any commercial product or activity by EPA or -the United States Government. \ No newline at end of file +the United States Government. +``` diff --git a/emanifest-js/package.json b/emanifest-js/package.json index 71b38603..4bdc1a7e 100644 --- a/emanifest-js/package.json +++ b/emanifest-js/package.json @@ -1,6 +1,6 @@ { "name": "emanifest", - "version": "0.1.0", + "version": "0.2.0", "description": "API client library, written in TypeScript, for using the EPA e-Manifest/RCRAInfo web services.", "type": "module", "main": "./dist/emanifest-lib.umd.cjs", diff --git a/emanifest-js/src/client.ts b/emanifest-js/src/client.ts index 00f83b24..66e07af4 100644 --- a/emanifest-js/src/client.ts +++ b/emanifest-js/src/client.ts @@ -1,9 +1,13 @@ +// noinspection JSUnusedGlobalSymbols + import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { AuthResponse, BillGetParameters, BillHistoryParameters, BillSearchParameters, + DateType, + dateTypeValues, ManifestCorrectionParameters, ManifestExistsResponse, ManifestSearchParameters, @@ -11,6 +15,8 @@ import { QuickerSign, RcraCode, SiteSearchParameters, + SiteType, + siteTypeValues, UserSearchParameters, } from './types'; @@ -24,6 +30,7 @@ interface RcraClientConfig { apiID?: string; apiKey?: string; authAuth?: Boolean; + validateInput?: Boolean; } export type RcraClientClass = typeof RcraClient; @@ -35,8 +42,14 @@ export type RcraClientClass = typeof RcraClient; * @param apiKey - The API key for the RCRAInfo. * @param authAuth - Automatically authenticate if necessary. By default, this is disabled. */ -export const newClient = ({ apiBaseURL, apiID, apiKey, authAuth }: RcraClientConfig = {}) => { - return new RcraClient(apiBaseURL, apiID, apiKey, authAuth); +export const newClient = ({ + apiBaseURL, + apiID, + apiKey, + authAuth = false, + validateInput = false, +}: RcraClientConfig = {}) => { + return new RcraClient(apiBaseURL, apiID, apiKey, authAuth, validateInput); }; /** @@ -51,17 +64,25 @@ export const newClient = ({ apiBaseURL, apiID, apiKey, authAuth }: RcraClientCon class RcraClient { private apiClient: AxiosInstance; env: RcrainfoEnv; - private apiID?: string; - private apiKey?: string; + private readonly apiID?: string; + private readonly apiKey?: string; token?: string; expiration?: string; autoAuth?: Boolean; - - constructor(apiBaseURL: RcrainfoEnv, apiID?: string, apiKey?: string, autoAuth: Boolean = false) { + validateInput?: Boolean; + + constructor( + apiBaseURL: RcrainfoEnv, + apiID?: string, + apiKey?: string, + autoAuth: Boolean = false, + validateInput: Boolean = false, + ) { this.env = apiBaseURL || RCRAINFO_PREPROD; this.apiID = apiID; this.apiKey = apiKey; this.autoAuth = autoAuth; + this.validateInput = validateInput; this.apiClient = axios.create({ baseURL: this.env, headers: { @@ -70,26 +91,26 @@ class RcraClient { }, }); + // Intercept all requests, make call to RCRAInfo auth service if necessary and add Authorization header this.apiClient.interceptors.request.use(async (config) => { - // if the request is for auth, don't add the token + // if the request is for auth service, don't add the token if (config.url?.includes('auth')) { return config; } - // if autoAuth is disabled return - if (!this.autoAuth) { - return config; - } - // If authAuth is enabled, check if we already have a token. If not, authenticate. - if (!this.token) { - // Check the RcraClient object for apiID and apiKey. - if (!this.apiID || !this.apiKey) { - // If there's no token and no apiID or apiKey, throw an error. We can't authenticate. - throw new Error('Please add API ID and Key to authenticate.'); + // if autoAuth is enabled, check if we already have a token. + if (this.autoAuth) { + // If we do not have a token, check if we have an apiID and apiKey. + if (!this.token) { + // If we have an apiID and apiKey, try to authenticate. + if (!this.apiID || !this.apiKey) { + // If there's no token and no apiID or apiKey, throw an error. We can't authenticate. + throw new Error('Please add API ID and Key to authenticate.'); + } + // If there's no token, but there is an apiID and apiKey, try to authenticate. + await this.authenticate().catch((err) => { + throw new Error(`Received an error while attempting to authenticate: ${err}`); + }); } - // If there's no token, but there is an apiID and apiKey, try to authenticate. - await this.authenticate().catch((err) => { - throw new Error(`Received an error while attempting to authenticate: ${err}`); - }); } config.headers.Authorization = `Bearer ${this.token}`; return config; @@ -113,7 +134,7 @@ class RcraClient { /** * Returns true if the client has a valid token. */ - isAuthenticated = (): boolean => { + public isAuthenticated = (): boolean => { return this.token !== undefined; }; @@ -124,6 +145,9 @@ class RcraClient { * @param stateCode */ public getStateWasteCodes = async (stateCode: string): Promise> => { + if (this.validateInput) { + this.validateStateCode(stateCode); + } return this.apiClient.get(`v1/lookup/state-waste-codes/${stateCode}`); }; @@ -174,10 +198,13 @@ class RcraClient { /** * Returns a list of all available DOT Hazard classes. */ - public getHazardClasses = async ( - shippingName?: string, - idNumber?: string, - ): Promise> => { + public getHazardClasses = async ({ + shippingName, + idNumber, + }: { + shippingName?: string; + idNumber?: string; + } = {}): Promise> => { if (shippingName || idNumber) { // if either shippingName or idNumber is provided, attempt to get by shipping name and id number if (!shippingName || !idNumber) { @@ -194,10 +221,13 @@ class RcraClient { /** * Returns a list of all available DOT packing groups. */ - public getPackingGroups = async ( - shippingName?: string, - idNumber?: string, - ): Promise> => { + public getPackingGroups = async ({ + shippingName, + idNumber, + }: { + shippingName?: string; + idNumber?: string; + } = {}): Promise> => { if (shippingName || idNumber) { // if either shippingName or idNumber is provided, attempt to get by shipping name and id number if (!shippingName || !idNumber) { @@ -217,6 +247,9 @@ class RcraClient { * Get a site by its EPA ID. */ public getSite = async (siteID: string): Promise => { + if (this.validateInput) { + this.validateSiteID(siteID); + } return this.apiClient.get(`v1/site-details/${siteID}`); }; @@ -224,6 +257,9 @@ class RcraClient { * Returns true if the site, by EPA ID, exists in RCRAInfo. */ public getSiteExists = async (siteID: string): Promise> => { + if (this.validateInput) { + this.validateSiteID(siteID); + } return this.apiClient.get(`v1/site-exists/${siteID}`); }; @@ -278,6 +314,9 @@ class RcraClient { * @param manifestTrackingNumber */ public deleteManifest = async (manifestTrackingNumber: string): Promise> => { + if (this.validateInput) { + this.validateMTN(manifestTrackingNumber); + } return this.apiClient.delete(`v1/emanifest/manifest/delete${manifestTrackingNumber}`); }; @@ -295,6 +334,9 @@ class RcraClient { * Retrieve information about all manifest correction versions by manifest tracking number */ public getManifestCorrections = async (manifestTrackingNumber: string): Promise> => { + if (this.validateInput) { + this.validateMTN(manifestTrackingNumber); + } return this.apiClient.get(`v1/emanifest/manifest/correction-details/${manifestTrackingNumber}`); }; @@ -319,14 +361,27 @@ class RcraClient { /** * Retrieve Manifest Tracking Numbers for provided site id. */ - public getMTN = async (siteID: string): Promise> => { + public getSiteMTN = async (siteID: string): Promise> => { + if (this.validateInput) { + this.validateSiteID(siteID); + } return this.apiClient.get(`v1/emanifest/manifest-tracking-numbers/${siteID}`); }; /** * Retrieve site ids for provided state (code) and site type (i.e. Generator, Tsdf, Transporter). */ - public getSiteID = async (stateCode: string, siteType: string): Promise> => { + public getStateSites = async ({ + stateCode, + siteType, + }: { + stateCode: string; + siteType: string; + }): Promise> => { + if (this.validateInput) { + this.validateSiteType(siteType); + this.validateStateCode(stateCode); + } return this.apiClient.get(`v1/emanifest/site-ids/${stateCode}/${siteType}`); }; @@ -334,6 +389,9 @@ class RcraClient { * Retrieve e-Manifest by provided manifest tracking number. */ public getManifest = async (manifestTrackingNumber: string): Promise> => { + if (this.validateInput) { + this.validateMTN(manifestTrackingNumber); + } return this.apiClient.get(`v1/emanifest/manifest/${manifestTrackingNumber}`); }; @@ -341,6 +399,18 @@ class RcraClient { * Retrieve manifest tracking numbers based on provided search criteria in JSON format. */ public searchManifest = async (parameters: ManifestSearchParameters): Promise> => { + if (this.validateInput) { + // Many of the search parameters are optional, we only want to validate them if they are provided. + if (parameters.dateType) { + this.validateDateType(parameters.dateType); + } + if (parameters.stateCode) { + this.validateStateCode(parameters.stateCode); + } + if (parameters.siteType) { + this.validateSiteType(parameters.siteType); + } + } return this.apiClient.post('v1/emanifest/manifest/search', parameters); }; @@ -355,15 +425,22 @@ class RcraClient { * Revert manifest in 'UnderCorrection' status to previous 'Corrected' or 'Signed' version. */ public revertManifest = async (manifestTrackingNumber: string): Promise> => { + if (this.validateInput) { + this.validateMTN(manifestTrackingNumber); + } return this.apiClient.get(`v1/emanifest/manifest/revert/${manifestTrackingNumber}`); }; /** * Performs 'quicker' signature for the entity within the manifest specified by given - * siteId and siteType. If siteType is 'Transporter', transporter order must be specified to + * siteID and siteType. If siteType is 'Transporter', transporter order must be specified to * indicate which transporter performs the signature. */ public SignManifest = async (parameters: QuickerSign): Promise> => { + if (this.validateInput) { + this.validateSiteID(parameters.siteID); + this.validateSiteType(parameters.siteType); + } return this.apiClient.post('v1/emanifest/manifest/quicker-sign', parameters); }; @@ -371,4 +448,43 @@ class RcraClient { // public correctManifest = async (): Promise> => { // return this.apiClient.post('v1/emanifest/manifest/correct'); // }; + + private validateSiteID = (siteID: string): void => { + if (!siteID || siteID === '') { + throw new Error('Site ID cannot be empty'); + } + if (siteID.length !== 12) { + throw new Error('siteID must be a string of length 12'); + } + }; + + private validateMTN = (manifestTrackingNumber: string): void => { + if (!manifestTrackingNumber || manifestTrackingNumber === '') { + throw new Error('manifestTrackingNumber cannot be empty'); + } + if (manifestTrackingNumber.length !== 12) { + throw new Error('manifestTrackingNumber must be a string of length 12'); + } + }; + + private validateStateCode = (stateCode: string): void => { + if (!stateCode || stateCode === '') { + throw new Error('StateCode cannot be empty'); + } + if (stateCode.length !== 2) { + throw new Error('StateCode must be 2 characters long'); + } + }; + + private validateSiteType = (siteType: SiteType): void => { + if (!siteTypeValues.includes(siteType)) { + throw new Error(`SiteType must be one of ${siteTypeValues}`); + } + }; + + private validateDateType = (dateType: DateType): void => { + if (!dateTypeValues.includes(dateType)) { + throw new Error(`dateType must be one of ${dateTypeValues}`); + } + }; } diff --git a/emanifest-js/src/index.ts b/emanifest-js/src/index.ts index 02b7ea05..6c2cfd2b 100644 --- a/emanifest-js/src/index.ts +++ b/emanifest-js/src/index.ts @@ -18,12 +18,14 @@ import { ManifestStatus, SubmissionType, PackingGroups, + DateType, } from './types'; export { RCRAINFO_PREPROD, RCRAINFO_PROD, newClient }; export type { AuthResponse, BillGetParameters, + DateType, BillHistoryParameters, BillSearchParameters, ManifestCorrectionParameters, diff --git a/emanifest-js/src/tests/client.spec.ts b/emanifest-js/src/tests/client.spec.ts index 2cd471b0..fafbf11e 100644 --- a/emanifest-js/src/tests/client.spec.ts +++ b/emanifest-js/src/tests/client.spec.ts @@ -1,5 +1,5 @@ import { AxiosError, AxiosResponse } from 'axios'; -import { MOCK_API_ID, MOCK_API_KEY, MOCK_PACKING_GROUPS, MOCK_TOKEN } from './mockConstants'; +import { MOCK_API_ID, MOCK_API_KEY, MOCK_BAD_SITE_ID, MOCK_PACKING_GROUPS, MOCK_TOKEN } from './mockConstants'; import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { newClient, RCRAINFO_PREPROD, RCRAINFO_PROD } from '../index'; // @ts-ignore @@ -60,6 +60,39 @@ describe('RcraClient', () => { }); }); +describe('RcraClient validation', () => { + it('is disabled by default', async () => { + const rcrainfo = newClient({ + apiBaseURL: RCRAINFO_PREPROD, + apiID: MOCK_API_ID, + apiKey: MOCK_API_KEY, + authAuth: true, + }); + await expect(() => rcrainfo.getSite(MOCK_BAD_SITE_ID)).rejects.toThrowError(); + }); + it('throws an error if stateCode is not two characters long', async () => { + const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, validateInput: true }); + await expect(() => rcrainfo.getStateWasteCodes('BAD_STATE_CODE')).rejects.toThrowError(); + }); + it('throws an error if siteID is not 12 characters long', async () => { + const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, validateInput: true }); + await expect(() => rcrainfo.getSite('lengthy_site_id_yo_yo')).rejects.toThrowError( + 'siteID must be a string of length 12', + ); + await expect(() => rcrainfo.getSite('12345')).rejects.toThrowError('siteID must be a string of length 12'); + }); + it('throws an error if siteID is empty', async () => { + const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, validateInput: true }); + // @ts-ignore + await expect(() => rcrainfo.getSite()).rejects.toThrowError('Site ID cannot be empty'); + await expect(() => rcrainfo.getSite('')).rejects.toThrowError(); + }); + it('throws an error if siteType is not one of acceptable enums', async () => { + const rcrainfo = newClient({ apiBaseURL: RCRAINFO_PREPROD, validateInput: true }); + await expect(() => rcrainfo.getStateSites({ stateCode: 'VA', siteType: 'bad_site_type' })).rejects.toThrowError(); + }); +}); + describe('emanifest package', () => { it('exports a URL constant RCRAINFO_PREPROD', () => { expect(RCRAINFO_PREPROD).toBeDefined(); diff --git a/emanifest-js/src/tests/mockConstants.ts b/emanifest-js/src/tests/mockConstants.ts index 89aeb91b..5b810e16 100644 --- a/emanifest-js/src/tests/mockConstants.ts +++ b/emanifest-js/src/tests/mockConstants.ts @@ -2,3 +2,4 @@ export const MOCK_API_ID = 'mockApiId'; export const MOCK_API_KEY = 'mockApiKey'; export const MOCK_TOKEN = 'mockToken'; export const MOCK_PACKING_GROUPS = ['I', 'II', 'III']; +export const MOCK_BAD_SITE_ID = 'badID'; diff --git a/emanifest-js/src/tests/mocks/handlers.js b/emanifest-js/src/tests/mocks/handlers.js index 20329532..5c103041 100644 --- a/emanifest-js/src/tests/mocks/handlers.js +++ b/emanifest-js/src/tests/mocks/handlers.js @@ -1,5 +1,5 @@ import { rest } from 'msw'; -import { MOCK_API_ID, MOCK_API_KEY, MOCK_PACKING_GROUPS, MOCK_TOKEN } from '../mockConstants'; +import { MOCK_API_ID, MOCK_API_KEY, MOCK_BAD_SITE_ID, MOCK_PACKING_GROUPS, MOCK_TOKEN } from '../mockConstants'; import { RCRAINFO_PREPROD } from '../../client'; export const handlers = [ @@ -18,4 +18,19 @@ export const handlers = [ } return res(ctx.status(401)); }), + // Request for a bad site ID (likely a better way to parameterize this) + rest.get(`${RCRAINFO_PREPROD}/v1/site-details/${MOCK_BAD_SITE_ID}`, (req, res, ctx) => { + if (req.headers.get('Authorization') === `Bearer ${MOCK_TOKEN}`) { + return res( + ctx.status(400), + ctx.json({ + code: 'E_SiteIdNotFound', + message: 'Site with Provided Site id is Not Found', + errorId: '41cb95ed-f477-41aa-83af-fc4a28efbfa5', + errorDate: '2023-08-11T18:10:45.979+00:00', + }), + ); + } + return res(ctx.status(401)); + }), ]; diff --git a/emanifest-js/src/types.ts b/emanifest-js/src/types.ts index b69fad2c..74d9ab83 100644 --- a/emanifest-js/src/types.ts +++ b/emanifest-js/src/types.ts @@ -18,7 +18,11 @@ export type ManifestStatus = export type SubmissionType = 'FullElectronic' | 'DataImage5Copy' | 'Hybrid' | 'Image'; export type OriginType = 'Web' | 'Service' | 'Mail'; -export type SiteType = 'Generator' | 'Tsdf' | 'Transporter' | 'Rejection_AlternateTsdf'; +export const siteTypeValues = ['Generator', 'Tsdf', 'Transporter', 'Rejection_AlternateTsdf']; +export type SiteType = (typeof siteTypeValues)[number]; + +export const dateTypeValues = ['CertifiedDate', 'ReceivedDate', 'ShippedDate', 'UpdatedDate']; +export type DateType = (typeof dateTypeValues)[number]; /** * structure of many codes used by the manifest (waste codes, management methods codes, etc.) @@ -85,7 +89,7 @@ export interface ManifestSearchParameters { stateCode: string; siteId: string; status: ManifestStatus; - dateType: 'CertifiedDate' | 'ReceivedDate' | 'ShippedDate' | 'UpdatedDate'; + dateType: DateType; siteType: SiteType; startDate: string; endDate: string; @@ -102,7 +106,7 @@ export interface ManifestExistsResponse { export interface QuickerSign { manifestTrackingNumbers: string[]; - siteId: string; + siteID: string; siteType: SiteType; printedSignatureName: string; printedSignatureDate: string;