Skip to content
Snippets Groups Projects
Commit 865d52d7 authored by Coby Sher's avatar Coby Sher Committed by Brian Perry
Browse files

Issue #3398376 by coby.sher, pratik_kamble: Get individual resource

parent 5352eeed
No related branches found
No related tags found
2 merge requests!28Canary -> Main prepping 0.2.0 release,!21Add option to fetch individual resource with get
Pipeline #56844 passed
Showing
with 220 additions and 71 deletions
---
"@drupal-api-client/json-api-client": minor
---
Replace the `get` method with `getResource` to fetch an individual resource and `getCollection` to fetch a collection.
---
"@drupal-api-client/json-api-client": minor
"@drupal-api-client/api-client": minor
---
Use named instead of default exports
......@@ -3,5 +3,8 @@
"parserOptions": {
"project": "./tsconfig.json"
},
"ignorePatterns": ["dist"]
"ignorePatterns": ["dist"],
"rules": {
"import/prefer-default-export": ["off"]
}
}
......@@ -113,6 +113,10 @@ use a filter. Please do not `cd` into the project directory and use `npm` or
This project is being developed with a slight variation of the [Drupal JavaScript coding standards](https://www.drupal.org/docs/develop/standards/javascript/javascript-coding-standards) in order to have a set of guidelines that work well with TypeScript. Since the AirBnb style guide does not officially have TypeScript support, we are using the [eslint-config-airbnb-typescript](https://www.npmjs.com/package/eslint-config-airbnb-typescript) package to add Typescript compatibility. We are also using [TSDoc](https://tsdoc.org/) instead of [JSDoc3](https://jsdoc.app/) to standardize our doc comments.
#### `eslint-config-airbnb-typescript` Overrides
- Use named exports instead of default exports
### Editor Integration
#### VSCode
......
......@@ -11,7 +11,7 @@
"devDependencies": {
"jsonapi-typescript": "^0.1.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
"vite": "^5.0.2"
},
"dependencies": {
"@drupal-api-client/api-client": "workspace:*",
......
import { Cache } from "@drupal-api-client/api-client";
import JsonApiClient from "@drupal-api-client/json-api-client";
import Jsona from "jsona";
import { JsonApiClient } from "@drupal-api-client/json-api-client";
import { Jsona } from "jsona";
import * as JSONAPI from "jsonapi-typescript";
import { Logger } from "tslog";
......@@ -64,13 +64,17 @@ async function main() {
debug: true,
});
const recipeCollection = await jsonApiClient.get<
const resourceId = "35f7cd32-2c54-49f2-8740-0b0ec2ba61f6";
const recipeCollection = await jsonApiClient.getCollection<
JSONAPI.CollectionResourceDoc<string, Recipe>
>("node--recipe");
console.log("JSON:API Collection", recipeCollection);
const collection = await jsonApiClient.get("node--recipe", { locale: "es" });
const collection = await jsonApiClient.getCollection("node--recipe", {
locale: "es",
});
console.log("JSON:API Collection", collection);
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
......@@ -79,24 +83,26 @@ async function main() {
/* Example using a deserializer */
const jsonApiClientJsona = new JsonApiClient(baseUrl, {
serializer: new Jsona(),
debug: true,
});
const jsonaCollection = await jsonApiClientJsona.get("node--recipe");
const jsonaCollection = await jsonApiClientJsona.getCollection(
"node--recipe",
);
console.log("JSON:API Collection deserialized via jsona", jsonaCollection);
/* Example using a default logger */
const jsonApiClientDefaultLogger = new JsonApiClient(baseUrl, {
debug: true,
});
const defaultLoggerCollection = await jsonApiClientDefaultLogger.get(
"node--recipe",
);
const defaultLoggerCollection =
await jsonApiClientDefaultLogger.getCollection("node--recipe");
console.log(
"JSON:API Collection with default logger",
defaultLoggerCollection,
);
/* Example using a filter as string */
const filterCollectionUsingQueryString = await jsonApiClient.get(
const filterCollectionUsingQueryString = await jsonApiClient.getCollection(
"node--recipe",
{ queryString: "filter[field_cooking_time][value]=60" },
);
......@@ -104,6 +110,24 @@ async function main() {
"JSON:API Collection with filter",
filterCollectionUsingQueryString,
);
/* Example fetching a single resource by ID */
const singleResource = await jsonApiClient.getResource(
"node--recipe",
resourceId,
);
console.log("JSON:API Single resource", singleResource);
const singleResourceSpanish = await jsonApiClient.getResource(
"node--recipe",
resourceId,
{
locale: "es",
},
);
console.log(
"JSON:API Single resource overriding default locale",
singleResourceSpanish,
);
}
main();
{
"compilerOptions": {
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
......
......@@ -45,7 +45,7 @@
"typedoc": "^0.24.8",
"typedoc-plugin-mdn-links": "^3.0.3",
"typescript": "^5.1.3",
"vitest": "0.34.1"
"vitest": "0.34.6"
},
"engines": {
"node": ">=18",
......
......@@ -4,7 +4,9 @@ This package contains the base class for the Drupal API Client. For more informa
## Installation
<!-- Add installation information when we know which namespace we are using -->
```shell
npm install @drupal-api-client/api-client
```
## Usage
......
......@@ -33,14 +33,14 @@
}
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.7.1",
"@types/isomorphic-fetch": "^0.0.36",
"@arethetypeswrong/cli": "^0.13.1",
"@types/isomorphic-fetch": "^0.0.39",
"@vitest/coverage-v8": "^0.34.1",
"isomorphic-fetch": "^3.0.0",
"msw": "0.0.0-fetch.rc-17",
"msw": "2.0.8",
"publint": "^0.2.0",
"tsup": "^7.2.0",
"vitest": "^0.34.1"
"tsup": "^7.3.0",
"vitest": "^0.34.6"
},
"typedocOptions": {
"entryPoints": [
......
......@@ -6,7 +6,7 @@ import defaultLogger from "./utils/defaultLogger";
* Base class providing common functionality for all API clients.
* @see {@link ApiClientOptions} and {@link BaseUrl}
*/
export default class ApiClient {
export class ApiClient {
/**
* {@link BaseUrl}
*/
......
import ApiClient from "./ApiClient";
import { ApiClient } from "./ApiClient";
export default ApiClient;
export * from "./types";
export { ApiClient };
import ApiClient from "../src/ApiClient";
import { ApiClient } from "../src/ApiClient";
const baseUrl = "https://dev-drupal-api-client-poc.pantheonsite.io";
const apiPrefix = "customprefix";
......
import ApiClient from "../src/ApiClient";
import { ApiClient } from "../src/ApiClient";
test('addAuthorizationHeader should add Basic authorization header if authentication type is "basic"', () => {
// Arrange
......
/* eslint-disable @typescript-eslint/no-unused-vars */
import customFetch from "isomorphic-fetch";
import ApiClient from "../src/ApiClient";
import { ApiClient } from "../src/ApiClient";
const baseUrl = "https://dev-drupal-api-client.poc";
......
import ApiClient from "../src/ApiClient";
import { ApiClient } from "../src/ApiClient";
const baseUrl = "https://dev-drupal-api-client-poc.pantheonsite.io";
......
......@@ -4,7 +4,7 @@ This package contains the `JsonApiClient` class which extends the base `ApiClien
## Installation
```
```shell
npm i @drupal-api-client/json-api-client
```
......@@ -57,4 +57,10 @@ const client = new JsonApiClient(myDrupalUrl, {
// the optional serializer will be used to serialize and deserialize data.
serializer: new Jsona(),
});
// fetch a single resource
const article = await client.getResource("node--article", "1234");
// fetch a collection of nodes
const articles = await client.getCollection("node--article");
```
......@@ -33,15 +33,15 @@
}
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.7.1",
"@types/jsonapi-serializer": "^3.6.6",
"@arethetypeswrong/cli": "^0.13.1",
"@types/jsonapi-serializer": "^3.6.8",
"@vitest/coverage-v8": "^0.34.1",
"jsona": "^1.11.0",
"jsonapi-serializer": "^3.6.9",
"msw": "0.0.0-fetch.rc-17",
"msw": "2.0.8",
"publint": "^0.2.0",
"tsup": "^7.2.0",
"vitest": "^0.34.1"
"tsup": "^7.3.0",
"vitest": "^0.34.6"
},
"dependencies": {
"@aws-crypto/sha256-js": "^5.1.0",
......
import { Sha256 } from "@aws-crypto/sha256-js";
import ApiClient, { BaseUrl } from "@drupal-api-client/api-client";
import { ApiClient, BaseUrl } from "@drupal-api-client/api-client";
import { toHex } from "@smithy/util-hex-encoding";
import type {
CreateCacheKeyParams,
EntityTypeWithBundle,
GetOptions,
JsonApiClientOptions,
......@@ -12,7 +13,7 @@ import type {
* @see {@link JsonApiClientOptions}
* @see {@link BaseUrl}
*/
export default class JsonApiClient extends ApiClient {
export class JsonApiClient extends ApiClient {
debug: JsonApiClientOptions["debug"];
/**
......@@ -29,7 +30,7 @@ export default class JsonApiClient extends ApiClient {
}
/**
* Retrieves data of a specific entity type and bundle from the JSON:API.
* Retrieves a collection of data of a specific entity type and bundle from the JSON:API.
* @param type - The type of resource to retrieve, in the format "entityType--bundle".
* For example, "node--page". {@link EntityTypeWithBundle}
* @param options - (Optional) Additional options for customizing the request. {@link GetOptions}
......@@ -41,38 +42,94 @@ export default class JsonApiClient extends ApiClient {
* const collection = await jsonApiClient.get<JSONAPI.CollectionResourceDoc<string, Recipe>>("node--recipe");
* ```
*/
async get<T>(type: EntityTypeWithBundle, options?: GetOptions) {
async getCollection<T>(type: EntityTypeWithBundle, options?: GetOptions) {
const [entityTypeId, bundleId] = type.split("--");
if (!entityTypeId || !bundleId) {
throw new TypeError(`type must be in the format "entityType--bundle"`);
}
const localeSegment = options?.locale || this.defaultLocale;
const queryString = options?.queryString ? `?${options?.queryString}` : "";
const queryString = options?.queryString;
const cacheKey = await JsonApiClient.getCacheKey(
const cacheKey = await JsonApiClient.createCacheKey({
entityTypeId,
bundleId,
localeSegment,
queryString,
);
});
const cachedResponse = await this.getCachedResponse<T>(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const apiUrl = this.createURL({
localeSegment,
entityTypeId,
bundleId,
queryString,
});
if (this.debug) {
this.log("verbose", `Fetching endpoint ${apiUrl}`);
}
const response = await this.fetch(apiUrl);
let json = await response.json();
json = this.serializer
? (this.serializer.deserialize(json) as T)
: (json as T);
if (this.cache) {
const cachedResponse = await this.cache.get<T>(cacheKey);
if (cachedResponse) {
if (this.debug) {
this.log("verbose", `Fetching from cache for key ${cacheKey}`);
}
return cachedResponse;
}
await this.cache?.set(cacheKey, json);
}
return json;
}
/**
* Retrieves data for a resource by ID of a specific entity type and bundle from the JSON:API.
* @param type - The type of resource to retrieve, in the format "entityType--bundle".
* For example, "node--page". {@link EntityTypeWithBundle}
* @param resourceId - The ID of the individual resource to retrieve.
* @param options - (Optional) Additional options for customizing the request. {@link GetOptions}
* @returns A Promise that resolves to the JSON data of the requested resource.
*
* @example
* Using JSONAPI.CollectionResourceDoc type from the jsonapi-typescript package
* ```ts
* const collection = await jsonApiClient.get<JSONAPI.CollectionResourceDoc<string, Recipe>>("node--recipe");
* ```
*/
async getResource<T>(
type: EntityTypeWithBundle,
resourceId: string,
options?: GetOptions,
) {
const [entityTypeId, bundleId] = type.split("--");
if (!entityTypeId || !bundleId) {
throw new TypeError(`type must be in the format "entityType--bundle"`);
}
const localeSegment = options?.locale || this.defaultLocale;
const queryString = options?.queryString;
const cacheKey = await JsonApiClient.createCacheKey({
entityTypeId,
bundleId,
resourceId,
localeSegment,
queryString,
});
const cachedResponse = await this.getCachedResponse<T>(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const apiUrl = this.createURL({
localeSegment,
entityTypeId,
bundleId,
resourceId,
queryString,
});
const apiUrlObject = new URL(
`${localeSegment ?? ""}/${
this.apiPrefix
}/${entityTypeId}/${bundleId}${queryString}`,
this.baseUrl,
);
const apiUrl = apiUrlObject.toString();
if (this.debug) {
this.log("verbose", `Fetching endpoint ${apiUrl}`);
}
......@@ -87,34 +144,77 @@ export default class JsonApiClient extends ApiClient {
return json;
}
createURL({
localeSegment,
entityTypeId,
bundleId,
resourceId,
queryString,
}: {
[key: string]: string | undefined;
}) {
const apiUrlObject = new URL(
`${localeSegment ?? ""}/${this.apiPrefix}/${entityTypeId}/${bundleId}${
resourceId ? `/${resourceId}` : ""
}${queryString ? `?${queryString}` : ""}`,
this.baseUrl,
);
const apiUrl = apiUrlObject.toString();
return apiUrl;
}
/**
* Retrieves a cached response from the cache.
* @param cacheKey - The cache key to use for retrieving the cached response.
* @returns A promise wrapping the cached response as a generic type.
*/
async getCachedResponse<T>(cacheKey: string) {
if (!this.cache) {
return null;
}
if (this.debug) {
this.log("verbose", `Checking cache for key ${cacheKey}...`);
}
const cachedResponse = await this.cache.get<T>(cacheKey);
if (!cachedResponse) {
if (this.debug) {
this.log("verbose", `No cached response found for key ${cacheKey}...`);
}
return null;
}
if (this.debug) {
this.log("verbose", `Found cached response for key ${cacheKey}...`);
}
return cachedResponse;
}
/**
* Generates a cache key based on the provided parameters.
*
* @param entityTypeId - The entity type identifier for caching.
* @param bundleId - The bundle identifier for caching.
* @param localeSegment - Optional. The locale segment used for cache key. Default is an empty string.
* @param queryString - Optional. The query string used for cache key. Default is an empty string.
*
* @params params - The parameters to use for generating the cache key. {@link createCacheKeyParams}
* @returns A promise wrapping the generated cache key as a string.
*
* @example
* // Generate a cache key with entityTypeId and bundleId only
* const key1 = await MyClass.getCacheKey('entity1', 'bundle1');
* const key1 = await MyClass.createCacheKey('entity1', 'bundle1');
* // key1: 'entity1--bundle1'
*
* @example
* // Generate a cache key with entityTypeId, bundleId, localeSegment, and queryString
* const key2 = await MyClass.getCacheKey('entity2', 'bundle2', 'en-US', 'param1=value1&param2=value2');
* const key2 = await MyClass.createCacheKey('entity2', 'bundle2', 'en-US', 'param1=value1&param2=value2');
* // key2: 'en-US--entity2--bundle2--<sha256_hash_of_query_string>'
*/
static async getCacheKey(
entityTypeId: string,
bundleId: string,
localeSegment?: string,
queryString?: string,
): Promise<string> {
static async createCacheKey({
entityTypeId,
bundleId,
localeSegment,
resourceId,
queryString,
}: CreateCacheKeyParams): Promise<string> {
const localePart = localeSegment ? `${localeSegment}--` : "";
let queryStringPart = "";
const id = resourceId ? `--${resourceId}` : "";
if (queryString) {
const hash = new Sha256();
hash.update(queryString);
......@@ -123,6 +223,6 @@ export default class JsonApiClient extends ApiClient {
queryStringPart = `--${hashResultHex}`;
}
return `${localePart}${entityTypeId}--${bundleId}${queryStringPart}`;
return `${localePart}${entityTypeId}--${bundleId}${id}${queryStringPart}`;
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment