From 35cc217b3df35a24259f59c30ec04f0b2d267ea9 Mon Sep 17 00:00:00 2001 From: Will Davis Date: Thu, 21 May 2020 11:09:27 -0400 Subject: [PATCH] feat(api): rework the sdk api to a more class-based approach (#8) * Create common building blocks to interact with the RDS API: RdsServer, RdsCatalog, and RdsDataProduct. * Add initial testing for these three classes. * Add dependencies for existing MTNA model libraries BREAKING CHANGE: No longer singletons, must instantiate the RdsServer, RdsCatalog, and RdsDataProduct via the constructor. RJ-3 --- README.md | 128 +++++++++++------- package-lock.json | 63 +++++++++ package.json | 11 +- resources/rds-logo.png | Bin 0 -> 29054 bytes src/models/async/async-resource.ts | 72 ++++++++++ src/models/async/index.ts | 2 + src/models/async/resolution-listener.ts | 3 + src/models/data-set-metadata.ts | 6 + src/{ => models}/datasets/amcharts.ts | 0 src/{ => models}/datasets/formatted.ts | 0 src/{ => models}/datasets/gcharts.ts | 0 src/{ => models}/datasets/index.ts | 0 src/{ => models}/datasets/plotly.ts | 0 src/models/http-response.ts | 4 + src/models/index.ts | 7 + src/{ => models}/parameters/common-query.ts | 0 src/{ => models}/parameters/format.ts | 0 src/{ => models}/parameters/index.ts | 0 src/{ => models}/parameters/select.ts | 0 src/{ => models}/parameters/tabulate.ts | 0 src/models/parsed-url.ts | 24 ++++ src/models/server/index.ts | 2 + src/models/server/information.ts | 8 ++ src/models/server/rds-version.ts | 12 ++ src/public_api.ts | 6 +- src/rds-catalog.spec.ts | 98 ++++++++++++++ src/rds-catalog.ts | 113 ++++++++++++++++ src/rds-data-product.spec.ts | 120 +++++++++++++++++ src/rds-data-product.ts | 123 ++++++++++++++++++ src/rds-query-controller.ts | 66 ---------- src/rds-server.spec.ts | 78 +++++++++++ src/rds-server.ts | 137 ++++++++++++++++---- src/utils/http.ts | 5 +- src/utils/index.ts | 1 + src/utils/url-parser.ts | 59 +++++++++ src/utils/url-serializers.ts | 4 +- test/rds-server.spec.ts | 9 -- tools/setupJest.js | 1 + 38 files changed, 1007 insertions(+), 155 deletions(-) create mode 100644 resources/rds-logo.png create mode 100644 src/models/async/async-resource.ts create mode 100644 src/models/async/index.ts create mode 100644 src/models/async/resolution-listener.ts create mode 100644 src/models/data-set-metadata.ts rename src/{ => models}/datasets/amcharts.ts (100%) rename src/{ => models}/datasets/formatted.ts (100%) rename src/{ => models}/datasets/gcharts.ts (100%) rename src/{ => models}/datasets/index.ts (100%) rename src/{ => models}/datasets/plotly.ts (100%) create mode 100644 src/models/http-response.ts create mode 100644 src/models/index.ts rename src/{ => models}/parameters/common-query.ts (100%) rename src/{ => models}/parameters/format.ts (100%) rename src/{ => models}/parameters/index.ts (100%) rename src/{ => models}/parameters/select.ts (100%) rename src/{ => models}/parameters/tabulate.ts (100%) create mode 100644 src/models/parsed-url.ts create mode 100644 src/models/server/index.ts create mode 100644 src/models/server/information.ts create mode 100644 src/models/server/rds-version.ts create mode 100644 src/rds-catalog.spec.ts create mode 100644 src/rds-catalog.ts create mode 100644 src/rds-data-product.spec.ts create mode 100644 src/rds-data-product.ts delete mode 100644 src/rds-query-controller.ts create mode 100644 src/rds-server.spec.ts create mode 100644 src/utils/url-parser.ts delete mode 100644 test/rds-server.spec.ts create mode 100644 tools/setupJest.js diff --git a/README.md b/README.md index 6c15472..d93b80a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=for-the-badge)](https://github.com/semantic-release/semantic-release) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=for-the-badge)](https://github.com/prettier/prettier) - +## Checkout our awesome [examples/showcases][examples] repo. +
+ + **Rich Data Services** (or **RDS**) is a suite of REST APIs designed by Metadata Technology North America (MTNA) to meet various needs for data engineers, managers, custodians, and consumers. RDS provides a range of services including data profiling, mapping, transformation, validation, ingestion, and dissemination. For more information about each of these APIs and how you can incorporate or consume them as part of your work flow please visit the MTNA website. @@ -18,7 +21,7 @@ Make RDS queries easy. Write strongly typed code. Use RDS-JS. ## References -[RDS SDK Documentation](https://mtna.github.io/rds-js/) | [RDS API Documentation](https://covid19.richdataservices.com/rds/swagger/) | [Examples](https://github.com/mtna/rds-js-examples) | [Contributing](CONTRIBUTING.md) | [Developer Documentation](DEVELOPER.md) | [Changelog](https://github.com/mtna/rds-js/releases) +[RDS SDK Documentation][docs] | [RDS API Documentation](https://covid19.richdataservices.com/rds/swagger/) | [Examples][examples] | [Contributing](CONTRIBUTING.md) | [Developer Documentation](DEVELOPER.md) | [Changelog](https://github.com/mtna/rds-js/releases) |---|---|---|---|---|---| ## Quick start @@ -31,46 +34,76 @@ Install the sdk into your project. npm install @rds/sdk ``` -#### Initialization - -Import `RdsServer` and initialize to indicate where the RDS API is hosted. This must be done a single time before performing any queries. +### The setup ```typescript -import { RdsServer } from '@rds/sdk'; -RdsServer.init('https://', 'covid19.richdataservices.com'); +import { RdsServer, RdsCatalog, RdsDataProduct } from '@rds/sdk'; + +// Instantiate a new server to define where the RDS API is hosted. +const server = new RdsServer('https://covid19.richdataservices.com/rds'); +// Instantiate a catalog that exists on the server +const catalog = new RdsCatalog(server, 'int'); +// Instantiate a data product that exists on the catalog +const dataProduct = new RdsDataProduct(catalog, 'jhu_country'); ``` -### RDS Query Controller +These are the basic, foundational building blocks of the RDS SDK. From here, we can explore what catalogs/data products exist on the server, details about them, subset the data through various queries, and downloading customized data packages. -This service is used to query data products for both record level and aggregate data. +See the [documentation][docs] for the full SDK API. -#### Count +--- -> Get the number of records in a dataset +### RdsServer -```typescript -import { HttpResponse, RdsQueryController } from '@rds/sdk'; +Represents a single RDS API server, provides methods to query server-level information. + +> Get the root catalog on the server +```ts +import { RdsServer } from '@rds/sdk'; +const server = new RdsServer('https://covid19.richdataservices.com/rds'); +server + .getRootCatalog() + .then(res=> + console.log(`There are ${res.parsedBody.catalogs.length} catalogs on this server!`) + ); +``` -const CATALOG_ID = 'covid19'; -const DATA_PRODUCT_ID = 'us_jhu_ccse_country'; +--- -RdsQueryController - .count(CATALOG_ID, DATA_PRODUCT_ID) - .then((res: HttpResponse) => - { console.log(`Found ${res.parsedBody} records!`); } +### RdsCatalog + +Represents a single catalog on a server, provides methods to query catalog related information. + +> Resolve properties about the catalog +```ts +import { RdsCatalog } from '@rds/sdk'; +// Given a previously instantiated server, like in the examples above +const catalog = new RdsCatalog(server, 'int'); +catalog + .resolve() + .then(()=> + catalog.name; // Name of catalog + catalog.description; // Catalog description + catalog.dataProducts; // All the data products on this catalog + // See the docs for all the possible properties ); ``` -#### Select +--- -> Running a select query on the specified data product returns record level microdata. +### RdsDataProduct -```typescript -import { AmchartsDataSet, HttpResponse, RdsQueryController, RdsSelectParameters } from '@rds/sdk'; +Represents a single data product within a catalog, provides methods to query data product related information. + +> Run a **select** query to get record level microdata. -const CATALOG_ID = 'covid19'; -const DATA_PRODUCT_ID = 'us_jhu_ccse_country'; -const PARAMS: RdsSelectParameters = { +```ts +import { AmchartsDataSet, HttpResponse, RdsDataProduct, RdsSelectParameters } from '@rds/sdk'; + +// Given the catalog from the above examples +const dataProduct = new RdsDataProduct(catalog, 'jhu_country'); +// Specify some parameters +const params: RdsSelectParameters = { cols: 'date_stamp,cnt_confirmed,cnt_death,cnt_recovered', where: '(iso3166_1=US)', metadata: true, @@ -78,33 +111,38 @@ const PARAMS: RdsSelectParameters = { format: 'amcharts' }; -RdsQueryController - .select(CATALOG_ID, DATA_PRODUCT_ID, PARAMS) +dataProduct + .select(params) .then((res: HttpResponse) => - { /** Make a cool visualization */ } + { /* Make a cool visualization */ } ); ``` -#### Tabulate - -> Running tabulations on the specified data product returns aggregate level data about the dimensions and measures specified. +> Run a **tabulation** to get aggregate level data about the dimensions and measures specified. ```typescript -import { AmchartsDataSet, HttpResponse, RdsQueryController, RdsTabulateParameters } from '@rds/sdk'; - -const CATALOG_ID = 'covid19'; -const DATA_PRODUCT_ID = 'us_jhu_ccse_country'; -const PARAMS: RdsTabulateParameters = { - dims: 'iso3166_1', - measure: 'cnt_confirmed:SUM(cnt_confirmed),cnt_death:SUM(cnt_death),cnt_recovered:SUM(cnt_recovered)', - orderby: 'cnt_confirmed DESC', +import { PlotlyDataSet, HttpResponse, RdsDataProduct, RdsTabulateParameters } from '@rds/sdk'; + +// Given the catalog from the above examples +const dataProduct = new RdsDataProduct(catalog, 'jhu_country'); +// Specify some parameters +const params: RdsTabulateParameters = { + dims: 'date_stamp,iso3166_1', + measure: 'cnt_confirmed:SUM(cnt_confirmed)', + where: '(year_stamp=2020) AND (iso3166_1=US OR iso3166_1=CA OR iso3166_1=ES OR iso3166_1=IT OR iso3166_1=CN)', + orderby: 'date_stamp ASC,iso3166_1 ASC', metadata: true, - limit: 10 + inject: true, + totals: true, + format: 'plotly_heatmap' }; -RdsQueryController - .tabulate(CATALOG_ID, DATA_PRODUCT_ID, PARAMS) - .then((res: HttpResponse) => - { /** Make a cool visualization */ } +dataProduct + .tabulate(params) + .then((res: HttpResponse) => + { /* Make a cool visualization */ } ); ``` + +[docs]: https://mtna.github.io/rds-js/ +[examples]: https://github.com/mtna/rds-js-examples \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e134842..b003af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3492,6 +3492,37 @@ "rimraf": "^2.5.2" } }, + "@mtna/model-base-ui": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@mtna/model-base-ui/-/model-base-ui-9.0.6.tgz", + "integrity": "sha512-TFBKZcbSLU6FG9BuUz7pCdxWbDgvpvwmJsGjCBOPr7UcUCW2n3KNTnCe6loi+BOAB0wYLY4yZQn7ht8wxdu+SA==" + }, + "@mtna/model-core-ui": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@mtna/model-core-ui/-/model-core-ui-10.0.0.tgz", + "integrity": "sha512-UFCpybNNksQfrnwY1hYtNiBgceBUr4gGJXG0r/pnFPcUXECeAzHSwO6yQjrYL6ORqKwpRTLZP1CJ5XU5FB8Www==" + }, + "@mtna/model-predefined-data-ui": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@mtna/model-predefined-data-ui/-/model-predefined-data-ui-1.1.6.tgz", + "integrity": "sha512-7PhCGJ0aCXrmT05T2VhX75CJtyYKlsxy11ZEdpemBDqEdX6lwSqN5amda0LtbdiiQ/AMuir/oiOyNz7bOZF7UA==" + }, + "@mtna/pojo-base-ui": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@mtna/pojo-base-ui/-/pojo-base-ui-9.0.2.tgz", + "integrity": "sha512-YVMR3bRYjNpB+zdfc4yVejkgeHm1+/YbuGVgtdsW79fUtM/Tgfa2oSDRhU3CQ3lCPbxeMsSzqi47N9L/z9LXwA==", + "requires": { + "tslib": "^1.10.0" + } + }, + "@mtna/pojo-consumer-ui": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mtna/pojo-consumer-ui/-/pojo-consumer-ui-1.1.9.tgz", + "integrity": "sha512-hLvoH07w/l3+i2SmD/pym+jfuD9i1/Q2wSfwkSGImQ1j6s/pI1FBx356b1Azqd/ZXTOy4yLhxTPHxaH2zRjjrg==", + "requires": { + "tslib": "^1.10.0" + } + }, "@mtna/prettier-config-ui": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@mtna/prettier-config-ui/-/prettier-config-ui-1.0.1.tgz", @@ -6863,6 +6894,16 @@ "cross-spawn": "^6.0.5" } }, + "cross-fetch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz", + "integrity": "sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw==", + "dev": true, + "requires": { + "node-fetch": "2.6.0", + "whatwg-fetch": "3.0.0" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -10540,6 +10581,16 @@ "jest-util": "^26.0.1" } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.0.0.tgz", @@ -19252,6 +19303,12 @@ "asap": "~2.0.3" } }, + "promise-polyfill": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz", + "integrity": "sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g==", + "dev": true + }, "prompt": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/prompt/-/prompt-1.0.0.tgz", @@ -23251,6 +23308,12 @@ "iconv-lite": "0.4.24" } }, + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==", + "dev": true + }, "whatwg-mimetype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", diff --git a/package.json b/package.json index 06c2559..c605103 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,11 @@ "prepare": "npm run snyk-protect" }, "dependencies": { + "@mtna/model-base-ui": "^9.0.6", + "@mtna/model-core-ui": "^10.0.0", + "@mtna/model-predefined-data-ui": "^1.1.6", + "@mtna/pojo-base-ui": "^9.0.2", + "@mtna/pojo-consumer-ui": "^1.1.9", "@types/google.visualization": "0.0.52", "snyk": "^1.321.0" }, @@ -68,6 +73,7 @@ "husky": "^1.0.1", "jest": "^26.0.1", "jest-config": "^26.0.1", + "jest-fetch-mock": "^3.0.3", "lint-staged": "^8.0.0", "lodash.camelcase": "^4.3.0", "prettier": "^1.14.3", @@ -111,7 +117,7 @@ "transform": { ".(ts|tsx)": "ts-jest" }, - "testEnvironment": "node", + "testEnvironment": "jsdom", "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", "moduleFileExtensions": [ "ts", @@ -132,6 +138,9 @@ }, "collectCoverageFrom": [ "src/*.{js,ts}" + ], + "setupFiles": [ + "./tools/setupJest.js" ] }, "prettier": "@mtna/prettier-config-ui", diff --git a/resources/rds-logo.png b/resources/rds-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b65148c9314d08cac8231260faaf3b175c884570 GIT binary patch literal 29054 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 literal 0 HcmV?d00001 diff --git a/src/models/async/async-resource.ts b/src/models/async/async-resource.ts new file mode 100644 index 0000000..35d610c --- /dev/null +++ b/src/models/async/async-resource.ts @@ -0,0 +1,72 @@ +import { ResolutionListener } from './resolution-listener'; + +/** + * An asyncronous resource that can be resolved. + */ +export abstract class AsyncResource { + /** Whether this resource is resolved */ + private resolved = false; + /** Whether this resource is resolving */ + private resolving = false; + /** Array of resolution listeners */ + private resolutionListeners: ResolutionListener[] = []; + + /** + * Create a new AsyncResource + * + * @param resolve whether to resolve the resource now, defaults to false + */ + constructor(resolve: boolean) { + if (resolve) { + this.resolve().catch(error => console.error('[@rds/sdk] AsyncResource: failed to resolve', error)); + } + } + + /** @returns whether this resource is resolved */ + isResolved(): boolean { + return this.resolved; + } + + /** @returns whether this resource is resolving */ + isResolving(): boolean { + return this.resolving; + } + + /** + * Register a listener that will emit when this resource has been resolved sucessfully. + * @param listener the listener for when this resource has been resolved + */ + registerResolutionListener(listener: ResolutionListener) { + this.resolutionListeners.push(listener); + } + + /** + * Resolve this resource. + * + * @returns a promise that completes once the resource is resolved + */ + abstract async resolve(): Promise; + + /** + * Setter for `resolved`. + * @param resolved whether this resource is resolved + */ + protected setResolved(resolved: boolean) { + this.resolved = resolved; + // When the resource is resolved, + // notify any resolution listeners + if (this.resolved) { + while (this.resolutionListeners.length) { + this.resolutionListeners.pop()?.resolved(); + } + } + } + + /** + * Setter for `resolving`. + * @param resolving whether the resource is resolving + */ + protected setResolving(resolving: boolean) { + this.resolving = resolving; + } +} diff --git a/src/models/async/index.ts b/src/models/async/index.ts new file mode 100644 index 0000000..6134d77 --- /dev/null +++ b/src/models/async/index.ts @@ -0,0 +1,2 @@ +export * from './async-resource'; +export * from './resolution-listener'; diff --git a/src/models/async/resolution-listener.ts b/src/models/async/resolution-listener.ts new file mode 100644 index 0000000..b3317ec --- /dev/null +++ b/src/models/async/resolution-listener.ts @@ -0,0 +1,3 @@ +export interface ResolutionListener { + resolved(): void; +} diff --git a/src/models/data-set-metadata.ts b/src/models/data-set-metadata.ts new file mode 100644 index 0000000..656c51d --- /dev/null +++ b/src/models/data-set-metadata.ts @@ -0,0 +1,6 @@ +import { Classification, RecordLayout } from '@mtna/pojo-consumer-ui'; + +export interface DataSetMetadata { + recordLayout?: RecordLayout; + classifications?: Classification[]; +} diff --git a/src/datasets/amcharts.ts b/src/models/datasets/amcharts.ts similarity index 100% rename from src/datasets/amcharts.ts rename to src/models/datasets/amcharts.ts diff --git a/src/datasets/formatted.ts b/src/models/datasets/formatted.ts similarity index 100% rename from src/datasets/formatted.ts rename to src/models/datasets/formatted.ts diff --git a/src/datasets/gcharts.ts b/src/models/datasets/gcharts.ts similarity index 100% rename from src/datasets/gcharts.ts rename to src/models/datasets/gcharts.ts diff --git a/src/datasets/index.ts b/src/models/datasets/index.ts similarity index 100% rename from src/datasets/index.ts rename to src/models/datasets/index.ts diff --git a/src/datasets/plotly.ts b/src/models/datasets/plotly.ts similarity index 100% rename from src/datasets/plotly.ts rename to src/models/datasets/plotly.ts diff --git a/src/models/http-response.ts b/src/models/http-response.ts new file mode 100644 index 0000000..d2c60fd --- /dev/null +++ b/src/models/http-response.ts @@ -0,0 +1,4 @@ +/** Typed http reponse */ +export interface HttpResponse extends Response { + parsedBody?: T; +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..5ab6df8 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,7 @@ +export * from './async/index'; +export * from './data-set-metadata'; +export * from './datasets/index'; +export * from './http-response'; +export * from './parameters/index'; +export * from './parsed-url'; +export * from './server/index'; diff --git a/src/parameters/common-query.ts b/src/models/parameters/common-query.ts similarity index 100% rename from src/parameters/common-query.ts rename to src/models/parameters/common-query.ts diff --git a/src/parameters/format.ts b/src/models/parameters/format.ts similarity index 100% rename from src/parameters/format.ts rename to src/models/parameters/format.ts diff --git a/src/parameters/index.ts b/src/models/parameters/index.ts similarity index 100% rename from src/parameters/index.ts rename to src/models/parameters/index.ts diff --git a/src/parameters/select.ts b/src/models/parameters/select.ts similarity index 100% rename from src/parameters/select.ts rename to src/models/parameters/select.ts diff --git a/src/parameters/tabulate.ts b/src/models/parameters/tabulate.ts similarity index 100% rename from src/parameters/tabulate.ts rename to src/models/parameters/tabulate.ts diff --git a/src/models/parsed-url.ts b/src/models/parsed-url.ts new file mode 100644 index 0000000..e4e706c --- /dev/null +++ b/src/models/parsed-url.ts @@ -0,0 +1,24 @@ +export class ParsedUrl { + /** + * @param source the original url that was parsed + * @param protocol the protocol declares how your web browser should communicate with a web server when sending or fetching a web page or document. + * @param host the subdomain (if it exists) and domain + * @param port the optional port number + * @param path the path typically refers to a file or directory on the web server + * @param segments the path divided into each segment + * @param query is represented by a question mark followed by one or more parameters. + * @param params are snippets of information found in the query string of a URL. An object keyed on the parameter name and the value is the paramter value + * @param hash or a fragment, is an internal page reference, sometimes called a named anchor + */ + constructor( + public source: string, + public protocol: string, + public host: string, + public port: string = '', + public path: string, + public segments: string[] = [], + public query: string = '', + public params: { [name: string]: string } = {}, + public hash: string = '' + ) {} +} diff --git a/src/models/server/index.ts b/src/models/server/index.ts new file mode 100644 index 0000000..8c67279 --- /dev/null +++ b/src/models/server/index.ts @@ -0,0 +1,2 @@ +export * from './information'; +export * from './rds-version'; diff --git a/src/models/server/information.ts b/src/models/server/information.ts new file mode 100644 index 0000000..2866d3f --- /dev/null +++ b/src/models/server/information.ts @@ -0,0 +1,8 @@ +export interface ServerInformation { + /** RDS API server name */ + name: string; + /** Version number */ + version: string; + /** Date the version was released */ + released: string; +} diff --git a/src/models/server/rds-version.ts b/src/models/server/rds-version.ts new file mode 100644 index 0000000..0673dfc --- /dev/null +++ b/src/models/server/rds-version.ts @@ -0,0 +1,12 @@ +export interface RdsVersion { + /** Version number */ + version: string; + /** Date the version was released */ + released: string; + added: string[]; + changed: string[]; + deprecated: string[]; + removed: string[]; + fixed: string[]; + security: string[]; +} diff --git a/src/public_api.ts b/src/public_api.ts index b0d4928..5f0c905 100644 --- a/src/public_api.ts +++ b/src/public_api.ts @@ -1,5 +1,5 @@ -export * from './datasets/index'; -export * from './parameters/index'; -export * from './rds-query-controller'; +export * from './models/index'; +export * from './rds-catalog'; +export * from './rds-data-product'; export * from './rds-server'; export * from './utils/index'; diff --git a/src/rds-catalog.spec.ts b/src/rds-catalog.spec.ts new file mode 100644 index 0000000..328e42a --- /dev/null +++ b/src/rds-catalog.spec.ts @@ -0,0 +1,98 @@ +import fetchMock from 'jest-fetch-mock'; + +import { RdsCatalog } from './rds-catalog'; +import { RdsDataProduct } from './rds-data-product'; +import { RdsServer } from './rds-server'; +import { HttpUtil } from './utils/http'; + +describe('RdsCatalog', () => { + const COVID_API_URL = 'https://covid19.richdataservices.com/rds'; + const CATALOG_ID = 'int'; + const server = new RdsServer(COVID_API_URL); + + it('can instantiate', () => { + expect(new RdsCatalog(server, CATALOG_ID)).toBeInstanceOf(RdsCatalog); + }); + + describe('Constructor', () => { + const catalog = new RdsCatalog(server, CATALOG_ID); + it('should set the api url', () => { + expect(catalog.apiUrl).toEqual(COVID_API_URL); + }); + + it('should set the catalog url', () => { + expect(catalog.catalogUrl).toEqual(`${COVID_API_URL}/api/catalog/${CATALOG_ID}`); + }); + }); + + describe('Get data product', () => { + it(`should create and return a new RdsDataProduct`, () => { + expect(new RdsCatalog(server, CATALOG_ID).getDataProduct('')).toBeInstanceOf(RdsDataProduct); + }); + }); + + describe('Get metadata', () => { + it(`should make an api request to ${COVID_API_URL}/api/catalog/{CATALOG_ID}/metadata`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const catalog = new RdsCatalog(server, CATALOG_ID); + expect.assertions(2); + return catalog.getMetadata().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/catalog/${CATALOG_ID}/metadata`); + spy.mockRestore(); + }); + }); + }); + + describe(`Resolve the catalog's properties`, () => { + let catalog: RdsCatalog; + beforeEach(() => { + catalog = new RdsCatalog(server, CATALOG_ID); + }); + it(`should set resolving to true before calling the api`, () => { + expect.assertions(1); + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation( + () => + new Promise(resolve => { + expect(catalog.isResolving()).toBe(true); + resolve(); + }) + ); + return catalog.resolve().then(() => { + spy.mockRestore(); + }); + }); + it(`should set resolving to false once the api request completes`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(() => new Promise(resolve => resolve())); + expect.assertions(1); + return catalog.resolve().then(() => { + expect(catalog.isResolving()).toBe(false); + spy.mockRestore(); + }); + }); + it(`should set resolved to true when the api request completes sucessfully`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(() => new Promise(resolve => resolve())); + expect.assertions(2); + expect(catalog.isResolved()).toBe(false); + return catalog.resolve().then(() => { + expect(catalog.isResolved()).toBe(true); + spy.mockRestore(); + }); + }); + it(`should set the catalog's properties once the api request completes sucessfully`, () => { + fetchMock.mockOnce(JSON.stringify({ catalogCount: 10 })); + expect.assertions(1); + return catalog.resolve().then(() => { + expect(catalog.catalogCount).toBe(10); + }); + }); + it(`should throw an error when the api request fails`, () => { + const fakeError = new Error('fake error message'); + fetchMock.mockRejectOnce(fakeError); + expect.assertions(1); + return catalog.resolve().catch(e => { + expect(e).toEqual(fakeError); + }); + }); + }); +}); diff --git a/src/rds-catalog.ts b/src/rds-catalog.ts new file mode 100644 index 0000000..cc72738 --- /dev/null +++ b/src/rds-catalog.ts @@ -0,0 +1,113 @@ +import { Catalog } from '@mtna/pojo-consumer-ui'; + +import { AsyncResource } from './models/async/async-resource'; +import { DataSetMetadata } from './models/data-set-metadata'; +import { HttpResponse } from './models/http-response'; +import { RdsDataProduct } from './rds-data-product'; +import { RdsServer } from './rds-server'; +import { HttpUtil } from './utils/http'; + +/** + * An instance of a RDS Catalog. + * A basic building block to interact with a RDS API. + * Includes methods to query catalog related information. + * + * @example + * ```ts + * const covidServer = new RdsServer('https://covid19.richdataservices.com/rds'); + * const covidCatalog = new RdsCatalog(covidServer, 'int'); + * covidCatalog + * .getMetadata() + * .then( + * res => console.log('Catalog metadata:', res.parsedBody), + * error => console.error('Oh no!', error) + * ); + * ``` + */ +export interface RdsCatalog extends Catalog {} +export class RdsCatalog extends AsyncResource { + /** The url to the RDS API */ + get apiUrl(): string { + return this.server.apiUrl; + } + + /** The url for catalog related API endpoints */ + get catalogUrl(): string { + return `${this.server.apiUrl}/api/catalog/${this.catalogId}`; + } + + /** + * Create a new RdsCatalog which provides + * methods to interact with catalog-related + * endpoints on the RDS API. + * + * @param server the RDS API server on which this catalog exists + * @param catalogId the ID of this specific catalog + * @param resolve whether to automatically start resolving all the catalog's own properties, defaults to false + */ + constructor(protected readonly server: RdsServer, public readonly catalogId: string, resolve = false) { + super(resolve); + } + + /** + * Create and get an instance of a RDS Data Product that exists on + * this RdsCatalog. This is a convenience method, these two code snippets + * are equivalent: + * ```ts + * const dataProduct = new RdsServer('https://covid19.richdataservices.com/rds') + * .getCatalog('int') + * .getDataProduct('jhu_country'); + * ``` + * and + * ```ts + * const server = new RdsServer('https://covid19.richdataservices.com/rds'); + * const catalog = new RdsCatalog(server, 'int'); + * const dataProduct = new RdsDataProduct(catalog, 'jhu_country'); + * ``` + * @param dataProductId the ID of the specific data product + * @param resolve whether to automatically start resolving all the data product's own properties, defaults to false + * @returns a new RdsDataProduct + */ + getDataProduct(dataProductId: string, resolve = false): RdsDataProduct { + return new RdsDataProduct(this, dataProductId, resolve); + } + + /** + * Get catalog metadata. + * This will retrieve the metadata for all of the data products + * that are in the specified catalog. For each data product there + * will be a record layout with its variables along with any + * classifications that are referenced by the variables. + * + * @returns metadata about the specified catalog + */ + async getMetadata(): Promise> { + return HttpUtil.get(`${this.catalogUrl}/metadata`); + } + + /** + * Resolve this catalog. + * This allows a more specific view of a catalog and + * its data products, it is a subset of the root catalog + * and holds no additional information to what can be + * found in the broader get catalog endpoint. + * + * @returns a promise that completes once the catalog is resolved + */ + async resolve(): Promise { + this.setResolving(true); + return new Promise((resolve, reject) => { + HttpUtil.get(`${this.catalogUrl}`) + .then((res: HttpResponse) => { + if (res?.parsedBody) { + // Assign all properties from the api response to this resource + Object.assign(this, res.parsedBody); + } + this.setResolved(true); + resolve(); + }) + .finally(() => this.setResolving(false)) + .catch(error => reject(error)); + }); + } +} diff --git a/src/rds-data-product.spec.ts b/src/rds-data-product.spec.ts new file mode 100644 index 0000000..b3b012b --- /dev/null +++ b/src/rds-data-product.spec.ts @@ -0,0 +1,120 @@ +import fetchMock from 'jest-fetch-mock'; + +import { RdsCatalog } from './rds-catalog'; +import { RdsDataProduct } from './rds-data-product'; +import { RdsServer } from './rds-server'; +import { HttpUtil } from './utils/http'; + +describe('RdsDataProduct', () => { + const COVID_API_URL = 'https://covid19.richdataservices.com/rds'; + const CATALOG_ID = 'int'; + const DATA_PRODUCT_ID = 'jhu_country'; + const server = new RdsServer(COVID_API_URL); + const catalog = new RdsCatalog(server, CATALOG_ID); + + it('can instantiate', () => { + expect(new RdsDataProduct(catalog, DATA_PRODUCT_ID)).toBeInstanceOf(RdsDataProduct); + }); + + describe('Constructor', () => { + const dataProduct = new RdsDataProduct(catalog, DATA_PRODUCT_ID); + it('should set the api url', () => { + expect(dataProduct.dataProductUrl).toEqual(`${COVID_API_URL}/api/catalog/${CATALOG_ID}/${DATA_PRODUCT_ID}`); + }); + it('should set the query url', () => { + expect(dataProduct.queryUrl).toEqual(`${COVID_API_URL}/api/query/${CATALOG_ID}/${DATA_PRODUCT_ID}`); + }); + }); + + describe('Get the record count', () => { + it(`should make an api request to ${COVID_API_URL}/api/query/{CATALOG_ID}/{DATA_PRODUCT_ID}/count`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const dataProduct = new RdsDataProduct(catalog, DATA_PRODUCT_ID); + expect.assertions(2); + return dataProduct.count().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/query/${CATALOG_ID}/${DATA_PRODUCT_ID}/count`); + spy.mockRestore(); + }); + }); + }); + + describe('Run a select query', () => { + it(`should make an api request to ${COVID_API_URL}/api/query/{CATALOG_ID}/{DATA_PRODUCT_ID}/select?`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const dataProduct = new RdsDataProduct(catalog, DATA_PRODUCT_ID); + expect.assertions(2); + return dataProduct.select().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/query/${CATALOG_ID}/${DATA_PRODUCT_ID}/select?`); + spy.mockRestore(); + }); + }); + }); + + describe('Run a tabulation', () => { + it(`should make an api request to ${COVID_API_URL}/api/query/{CATALOG_ID}/{DATA_PRODUCT_ID}/tabulate?`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const dataProduct = new RdsDataProduct(catalog, DATA_PRODUCT_ID); + expect.assertions(2); + return dataProduct.tabulate().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/query/${CATALOG_ID}/${DATA_PRODUCT_ID}/tabulate?`); + spy.mockRestore(); + }); + }); + }); + + describe(`Resolve the data product's properties`, () => { + let dataProduct: RdsDataProduct; + beforeEach(() => { + dataProduct = new RdsDataProduct(catalog, DATA_PRODUCT_ID); + }); + + it(`should set resolving to true before calling the api`, () => { + expect.assertions(1); + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation( + () => + new Promise(resolve => { + expect(dataProduct.isResolving()).toBe(true); + resolve(); + }) + ); + return dataProduct.resolve().then(() => { + spy.mockRestore(); + }); + }); + it(`should set resolving to false once the api request completes`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(() => new Promise(resolve => resolve())); + expect.assertions(1); + return dataProduct.resolve().then(() => { + expect(dataProduct.isResolving()).toBe(false); + spy.mockRestore(); + }); + }); + it(`should set resolved to true when the api request completes sucessfully`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(() => new Promise(resolve => resolve())); + expect.assertions(2); + expect(dataProduct.isResolved()).toBe(false); + return dataProduct.resolve().then(() => { + expect(dataProduct.isResolved()).toBe(true); + spy.mockRestore(); + }); + }); + it(`should set the data product's properties once the api request completes sucessfully`, () => { + fetchMock.mockOnce(JSON.stringify({ description: 'fake description' })); + expect.assertions(1); + return dataProduct.resolve().then(() => { + expect(dataProduct.description).toBe('fake description'); + }); + }); + it(`should throw an error when the api request fails`, () => { + const fakeError = new Error('fake error message'); + fetchMock.mockRejectOnce(fakeError); + expect.assertions(1); + return dataProduct.resolve().catch(e => { + expect(e).toEqual(fakeError); + }); + }); + }); +}); diff --git a/src/rds-data-product.ts b/src/rds-data-product.ts new file mode 100644 index 0000000..cbf2c3a --- /dev/null +++ b/src/rds-data-product.ts @@ -0,0 +1,123 @@ +import { DataProduct } from '@mtna/pojo-consumer-ui'; + +import { AsyncResource } from './models/async/async-resource'; +import { FormattedDataSet } from './models/datasets/formatted'; +import { HttpResponse } from './models/http-response'; +import { RdsSelectParameters } from './models/parameters/select'; +import { RdsTabulateParameters } from './models/parameters/tabulate'; +import { RdsCatalog } from './rds-catalog'; +import { HttpUtil } from './utils/http'; +import { serializeRdsParameters } from './utils/url-serializers'; + +/** + * An instance of a RDS Data Product. + * A basic building block to interact with a RDS API. + * Includes methods to query data product related information. + * + * @example + * ```ts + * const covidServer = new RdsServer('https://covid19.richdataservices.com/rds'); + * const covidCatalog = new RdsCatalog(covidServer, 'int'); + * const dataProduct = new RdsDataProduct(covidCatalog, 'jhu_country'); + * dataProduct + * .select() + * .then( + * res => console.log('Data:', res.parsedBody), + * error => console.error('Oh no!', error) + * ); + * ``` + */ +export interface RdsDataProduct extends DataProduct {} +export class RdsDataProduct extends AsyncResource { + /** The url for data product related API endpoints. */ + get dataProductUrl(): string { + return `${this.catalog.catalogUrl}/${this.dataProductId}`; + } + + /** The url for query related API endpoints for this specific data product. */ + get queryUrl(): string { + return `${this.catalog.apiUrl}/api/query/${this.catalog.catalogId}/${this.dataProductId}`; + } + + /** + * Create a new RdsDataProduct which provides + * methods to interact with data product related + * endpoints on the RDS API. + * + * @param catalog the RDS Catalog on which this data product exists + * @param dataProductId the ID of this specific data product + * @param resolve whether to automatically start resolving all the data products's own properties, defaults to false + */ + constructor(protected readonly catalog: RdsCatalog, public readonly dataProductId: string, resolve = false) { + super(resolve); + } + + /** + * Get data product. + * This shows a single data product in a catalog, there is no + * additional information in this call, it simply limits the + * scope of what is returned. + * + * @returns a promise that completes once the data product is resolved + */ + async resolve(): Promise { + this.setResolving(true); + return new Promise((resolve, reject) => { + HttpUtil.get(`${this.dataProductUrl}`) + .then((res: HttpResponse) => { + if (res?.parsedBody) { + // Assign all properties from the api response to this resource + Object.assign(this, res.parsedBody); + } + this.setResolved(true); + resolve(); + }) + .finally(() => this.setResolving(false)) + .catch(error => reject(error)); + }); + } + + //#region QUERY ENDPOINTS + + /** + * Get record count. + * Runs a query to count the records of the specified data product. + * + * @returns the number of records within the data product. + */ + async count(): Promise> { + return HttpUtil.get(`${this.queryUrl}/count`); + } + + /** + * Run select query. + * Running a select query on the specified data product returns record level microdata. + * A variety of querying techniques can be used to subset, compute, order, filter and format the results. + * + * @param parameters Optional parameters for an RDS select query + * @returns Record level microdata, the shape will vary depending on the `format` query paramter. + */ + async select(parameters?: RdsSelectParameters): Promise> { + return HttpUtil.get( + `${this.queryUrl}/select?${serializeRdsParameters(parameters)}` + ); + } + + /** + * Run tabulation query. + * Running tabulations on the specified data product returns + * aggregate level data about the dimensions and measures specified. + * A variety of querying techniques can be used to subset, compute, order, filter and format the results. + * + * @param parameters Optional parameters for an RDS tabulate query + * @returns Aggregate level data about the dimensions and measures specified. + * The shape will vary depending on the `format` query paramter. + */ + async tabulate(parameters?: RdsTabulateParameters): Promise> { + return HttpUtil.get( + `${this.queryUrl}/tabulate?${serializeRdsParameters(parameters)}` + ); + } + + //#endregion QUERY ENDPOINTS +} diff --git a/src/rds-query-controller.ts b/src/rds-query-controller.ts deleted file mode 100644 index e8bb781..0000000 --- a/src/rds-query-controller.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { FormattedDataSet } from './datasets/formatted'; -import { RdsSelectParameters } from './parameters/select'; -import { RdsTabulateParameters } from './parameters/tabulate'; -import { RdsServer } from './rds-server'; -import { HttpResponse, HttpUtil } from './utils/http'; -import { serializeRdsParameters } from './utils/url-serializers'; - -/** - * Static functions to interact with the RDS API's QueryController endpoints. - */ -export class RdsQueryController { - /** Base url for the RDS Query Controller */ - protected static get queryUrl(): string { - return `${RdsServer.getInstance().apiUrl}/query`; - } - - /** - * Get record count. - * Runs a query to count the records of the specified data product. - * - * @param catalogId The ID of the catalog that contains the data product. - * @param dataProductId The ID of the data product to query. - * @returns the number of records within the data product. - */ - static async count(catalogId: string, dataProductId: string): Promise> { - return HttpUtil.get(`${this.queryUrl}/${catalogId}/${dataProductId}/count`); - } - - /** - * Run select query. - * Running a select query on the specified data product returns record level microdata. - * A variety of querying techniques can be used to subset, compute, order, filter and format the results. - * - * @param catalogId The ID of the catalog that contains the data product. - * @param dataProductId The ID of the data product to query. - * @param parameters Optional parameters for an RDS select query - * @returns Record level microdata, the shape will vary depending on the `format` query paramter. - */ - static async select( - catalogId: string, - dataProductId: string, - parameters?: RdsSelectParameters - ): Promise> { - return HttpUtil.get(`${this.queryUrl}/${catalogId}/${dataProductId}/select?${serializeRdsParameters(parameters)}`); - } - - /** - * Run tabulation query. - * Running tabulations on the specified data product returns - * aggregate level data about the dimensions and measures specified. - * A variety of querying techniques can be used to subset, compute, order, filter and format the results. - * - * @param catalogId The ID of the catalog that contains the data product. - * @param dataProductId The ID of the data product to query. - * @param parameters Optional parameters for an RDS tabulate query - * @returns Aggregate level data about the dimensions and measures specified. - * The shape will vary depending on the `format` query paramter. - */ - static async tabulate( - catalogId: string, - dataProductId: string, - parameters?: RdsTabulateParameters - ): Promise> { - return HttpUtil.get(`${this.queryUrl}/${catalogId}/${dataProductId}/tabulate?${serializeRdsParameters(parameters)}`); - } -} diff --git a/src/rds-server.spec.ts b/src/rds-server.spec.ts new file mode 100644 index 0000000..3e02d14 --- /dev/null +++ b/src/rds-server.spec.ts @@ -0,0 +1,78 @@ +import { RdsCatalog } from './rds-catalog'; +import { RdsServer } from './rds-server'; +import { HttpUtil } from './utils/http'; + +describe('RdsServer', () => { + const COVID_API_URL = 'https://covid19.richdataservices.com/rds'; + + it('can instantiate', () => { + expect(new RdsServer(COVID_API_URL)).toBeInstanceOf(RdsServer); + }); + + it('can instantiate via factory method', () => { + expect(RdsServer.fromUrlParts('https', 'covis19.richdataservices.com', '/rds')).toBeInstanceOf(RdsServer); + }); + + describe('Constructor', () => { + it('should set the api url', () => { + const server = new RdsServer(COVID_API_URL); + expect(server.apiUrl).toEqual(COVID_API_URL); + }); + + it('should parse the url into partials', () => { + const server = new RdsServer(COVID_API_URL); + expect(server.parsedUrl).toBeDefined(); + expect(server.parsedUrl.protocol).toEqual('https'); + expect(server.parsedUrl.host).toEqual('covid19.richdataservices.com'); + expect(server.parsedUrl.path).toEqual('/rds'); + }); + + it('should remove trailing slashes from the url', () => { + const server = new RdsServer(`${COVID_API_URL}/`); + expect(server.parsedUrl.source).toEqual(COVID_API_URL); + expect(server.apiUrl).toEqual(COVID_API_URL); + }); + }); + + describe('Get catalog', () => { + it(`should create and return a new RdsCatalog`, () => { + expect(new RdsServer(COVID_API_URL).getCatalog('')).toBeInstanceOf(RdsCatalog); + }); + }); + + describe('Get changelog', () => { + it(`should make an api request to ${COVID_API_URL}/api/server/changelog`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const server = new RdsServer(COVID_API_URL); + return server.getChangelog().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/server/changelog`); + spy.mockRestore(); + }); + }); + }); + + describe('Get info', () => { + it(`should make an api request to ${COVID_API_URL}/api/server/info`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const server = new RdsServer(COVID_API_URL); + return server.getInfo().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/server/info`); + spy.mockRestore(); + }); + }); + }); + + describe('Get root catalog', () => { + it(`should make an api request to ${COVID_API_URL}/api/catalog`, () => { + const spy = jest.spyOn(HttpUtil, 'get').mockImplementation(); + const server = new RdsServer(COVID_API_URL); + return server.getRootCatalog().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`${COVID_API_URL}/api/catalog`); + spy.mockRestore(); + }); + }); + }); +}); diff --git a/src/rds-server.ts b/src/rds-server.ts index 2e468ab..b4a3c11 100644 --- a/src/rds-server.ts +++ b/src/rds-server.ts @@ -1,41 +1,128 @@ +import { Catalog } from '@mtna/pojo-consumer-ui'; + +import { HttpResponse } from './models/http-response'; +import { ParsedUrl } from './models/parsed-url'; +import { RdsVersion } from './models/server'; +import { ServerInformation } from './models/server/information'; +import { RdsCatalog } from './rds-catalog'; +import { HttpUtil } from './utils/http'; +import { _parseUrl } from './utils/url-parser'; + /** - * Singleton class that holds the RDS API server information. - * Initialization required in order to use the sdk. + * An instance of a RDS API server. + * A basic building block to interact with a RDS API. + * Includes methods to query server-level information. + * + * @example + * ```ts + * const covidServer = new RdsServer('https://covid19.richdataservices.com/rds'); + * covidServer + * .getInfo() + * .then((res: HttpResponse) => + * { console.log('Server info:', res.parsedBody); } + * ); + * ``` */ export class RdsServer { - private static instance: RdsServer; - - /** @returns the RDS API url */ - get apiUrl(): string { - return `${this.protocol}${this.domain}${this.port ? ':' + this.port : ''}${this.path}/api`; + /** + * Factory method for instantiating an RdsServer + * from the individual url parts. + * + * @example + * ```ts + * const server = RdsServer.fromUrlParts('https://','covid19.richdataservices.com', '/rds'); + * ``` + * + * @param protocol The protocol used on the site the RDS API is hosted, defaults to 'https' + * @param domain The domain under which the RDS API is hosted, i.e. 'covid19.richdataservices.com' + * @param path The path where the RDS API is hosted, defaults to '/rds' + * @param port Optional port where the RDS API is hosted, or undefined if not applicable + * + * @returns a new RdsServer + */ + static fromUrlParts(protocol = 'https', domain: string, path = '/rds', port?: string | undefined): RdsServer { + return new this(`${protocol}://${domain}${port ? ':' + port : ''}${path}`); } - private constructor(private protocol: string, private domain: string, private port: string | undefined, private path: string) {} + /** The base url to the RDS API, i.e. `https://covid19.richdataservices.com/rds` */ + readonly apiUrl: string; + /** The url parsed out into its partials */ + readonly parsedUrl: ParsedUrl; - public static getInstance(): RdsServer { - if (!RdsServer.instance) { - throw new Error('RdsServer has not been initialized. You must call init before getting the instance.'); - } + /** The url for the server related API endpoints */ + get serverUrl(): string { + return `${this.apiUrl}/api/server`; + } - return RdsServer.instance; + /** + * Create a new instance of a RDS API Server. + * @param url The full URL to the RDS API, i.e. `https://covid19.richdataservices.com/rds` + */ + constructor(url: string) { + // Remove any trailing slashes + // and parse url + this.parsedUrl = _parseUrl(url.replace(/\/+$/, '')); + // Construct the api url + this.apiUrl = `${this.parsedUrl.protocol}://${this.parsedUrl.host}${this.parsedUrl.port ? ':' + this.parsedUrl.port : ''}${ + this.parsedUrl.path + }`; } /** - * Initialize the RDS server once to configure where the RDS API instance is hosted. + * Create and get an instance of a RDS Catalog that exists on + * this RdsServer. This is a convenience method, these two code snippets + * are equivalent: + * ```ts + * const catalog = new RdsServer('https://covid19.richdataservices.com/rds') + * .getCatalog('int'); + * ``` + * and + * ```ts + * const server = new RdsServer('https://covid19.richdataservices.com/rds'); + * const catalog = new RdsCatalog(server, 'int'); + * ``` + * @param catalogId the ID of the specific catalog + * @param resolve whether to automatically start resolving all the catalog's own properties, defaults to false + * @returns a new RdsCatalog + */ + getCatalog(catalogId: string, resolve = false): RdsCatalog { + return new RdsCatalog(this, catalogId, resolve); + } + + /** + * Get changelog. + * Shows the change log of RDS providing version information about + * additions, changes, deprecations, fixed bugs, removals, and security enhancements. * - * @param protocol The protocol used on the site the RDS API is hosted, defaults to 'https://' - * @param domain The domain under which the RDS API is hosted, defaults to 'covid19.richdataservices.com' - * @param port Optional port where the RDS API is hosted, or undefined if not applicable - * @param path The path where the RDS API is hosted, defaults to '/rds' - * @returns the initialized `RdsServer` instance. + * @returns an array of information about each version and the changes between them. */ - public static init(protocol = 'https://', domain = 'covid19.richdataservices.com', port?: string | undefined, path = '/rds'): RdsServer { - if (RdsServer.instance) { - throw new Error('The RdsServer can only be initialized once.'); - } + async getChangelog(): Promise> { + return HttpUtil.get(`${this.serverUrl}/changelog`); + } - RdsServer.instance = new RdsServer(protocol, domain, port, path); + /** + * Get server information. + * Provides the server information for RDS. + * + * @returns information about the RDS Server + */ + async getInfo(): Promise> { + return HttpUtil.get(`${this.serverUrl}/info`); + } - return RdsServer.instance; + /** + * Get the root catalog of the system. + * This will hold a list of all the catalogs and data products + * that are available on this server. This would ideally be + * used to power an entry point into an application. + * The catalog provides a starting point for users to view + * what is available to them and drill down into a catalog + * or data product of their interest. The catalogs and products + * may have descriptive metadata on them that is useful to display. + * + * @returns the root catalog of the RDS server + */ + async getRootCatalog(): Promise> { + return HttpUtil.get(`${this.apiUrl}/api/catalog`); } } diff --git a/src/utils/http.ts b/src/utils/http.ts index 38c9b95..75bbbe1 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -1,7 +1,4 @@ -/** Typed http reponse */ -export interface HttpResponse extends Response { - parsedBody?: T; -} +import { HttpResponse } from '../models/http-response'; /** * Utility to make strongly typed HTTP requests. diff --git a/src/utils/index.ts b/src/utils/index.ts index 83c3a57..42e1b64 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './http'; +export * from './url-parser'; export * from './url-serializers'; diff --git a/src/utils/url-parser.ts b/src/utils/url-parser.ts new file mode 100644 index 0000000..701330e --- /dev/null +++ b/src/utils/url-parser.ts @@ -0,0 +1,59 @@ +import { ParsedUrl } from '../models/parsed-url'; + +/** + * This function creates a new anchor element and uses location + * properties (inherent) to get the desired URL data. Some String + * operations are used (to normalize results across browsers). + * + * @remarks + * References JS implementation from + * + * @example + * ```ts + * const myURL = _parseUrl('http://abc.com:8080/dir/index.html?id=255&m=hello#top'); + * myURL.source; // = 'http://abc.com:8080/dir/index.html?id=255&m=hello#top' + * myURL.protocol; // = 'http' + * myURL.host; // = 'abc.com' + * myURL.port; // = '8080' + * myURL.path; // = '/dir/index.html' + * myURL.segments; // = Array = ['dir', 'index.html'] + * myURL.query; // = '?id=255&m=hello' + * myURL.params; // = Object = { id: 255, m: hello } + * myURL.hash; // = 'top' + * ``` + * + * @param url the url to parse + * @returns the parsed url + */ +export function _parseUrl(url: string): ParsedUrl { + const a = document.createElement('a'); + a.href = url; + + const parsedUrl = new ParsedUrl( + url, + a.protocol.replace(':', ''), + a.hostname, + a.port, + a.pathname.replace(/^([^/])/, '/$1'), + a.pathname.replace(/^\//, '').split('/'), + a.search, + (function() { + const params: { [name: string]: string } = {}; + const seg = a.search.replace(/^\?/, '').split('&'); + + for (let i = 0; i < seg.length; i++) { + if (!seg[i]) { + continue; + } + const s = seg[i].split('='); + params[s[0]] = s[1]; + } + return params; + })(), + a.hash.replace('#', '') + ); + + // Clean up and remove anchor element + a.remove(); + return parsedUrl; +} diff --git a/src/utils/url-serializers.ts b/src/utils/url-serializers.ts index 2d66951..62ee0df 100644 --- a/src/utils/url-serializers.ts +++ b/src/utils/url-serializers.ts @@ -1,5 +1,5 @@ -import { RdsSelectParameters } from '../parameters/select'; -import { RdsTabulateParameters } from '../parameters/tabulate'; +import { RdsSelectParameters } from '../models/parameters/select'; +import { RdsTabulateParameters } from '../models/parameters/tabulate'; /** * Serialize a parameter that has an array of values into a url query parameter. diff --git a/test/rds-server.spec.ts b/test/rds-server.spec.ts deleted file mode 100644 index 4b0c5c5..0000000 --- a/test/rds-server.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RdsServer } from '../src/rds-server'; - -// TODO write some tests :) -describe('RdsServer', () => { - it('Can get an instance after initialized', () => { - RdsServer.init(); - expect(RdsServer.getInstance()).toBeInstanceOf(RdsServer); - }); -}); diff --git a/tools/setupJest.js b/tools/setupJest.js new file mode 100644 index 0000000..d36d111 --- /dev/null +++ b/tools/setupJest.js @@ -0,0 +1 @@ +require('jest-fetch-mock').enableMocks();