Commit f719257f authored by Coby Sher's avatar Coby Sher Committed by Brian Perry
Browse files

Issue #3280391 by coby.sher: Control If API Requests are Anonymous or Authenticated

parent e883bbd4
Loading
Loading
Loading
Loading
Loading
+17 −9
Original line number Diff line number Diff line
@@ -231,15 +231,17 @@ class DrupalState {
   * Fetches data using  our fetch method.
   * @param endpoint the assembled JSON:API endpoint
   * @param res response object
   * @param anon make the request anonymously if true
   * @returns data fetched from JSON:API endpoint
   */
  async fetchData(
    endpoint: string,
    res: ServerResponse | boolean = false
    res: ServerResponse | boolean = false,
    anon = false
  ): Promise<TJsonApiBody | void> {
    let requestInit = {};
    let authHeader = '';
    if (this.clientId && this.clientSecret) {
    if (this.clientId && this.clientSecret && !anon) {
      const headers = new Headers();
      authHeader = await this.getAuthHeader();
      headers.append('Authorization', authHeader);
@@ -288,6 +290,7 @@ class DrupalState {
   * @param options.res - response object
   * @param options.params - user provided JSON:API parameter string or DrupalJsonApiParams object
   * @param options.refresh - a boolean value. If true, ignore local state.
   * @param options.anon - a boolean value. If true, send the request without the authentication header if valid credentials exist.
   * @returns a promise containing deserialized JSON:API data for the requested
   * object
   *
@@ -308,6 +311,7 @@ class DrupalState {
    res,
    params,
    refresh = false,
    anon = false,
    query = false,
  }: GetObjectByPathParams): Promise<PartialState<State> | void> {
    if (query) {
@@ -325,7 +329,7 @@ class DrupalState {
      // TODO - abstract helper method to assemble requestInit and authHeader
      let requestInit = {};
      let authHeader = '';
      if (this.clientId && this.clientSecret) {
      if (this.clientId && this.clientSecret && !anon) {
        const headers = new Headers();
        authHeader = await this.getAuthHeader();
        headers.append('Authorization', authHeader);
@@ -394,10 +398,11 @@ class DrupalState {
   * @remarks The query option was experimental and is now deprecated
   * @param options.objectName - Name of object to fetch
   * @param options.id - id of a specific resource
   * @param options.res response object
   * @param options.params user provided JSON:API parameter string or DrupalJsonApiParams object
   * @param options.all a boolean value. If true, fetch all objects in a collection.
   * @param options.refresh a boolean value. If true, ignore local state.
   * @param options.res - response object
   * @param options.params - user provided JSON:API parameter string or DrupalJsonApiParams object
   * @param options.all - a boolean value. If true, fetch all objects in a collection.
   * @param options.refresh - a boolean value. If true, ignore local state.
   * @param options.anon - a boolean value. If true, send the request without the authentication header if valid credentials exist.
   * @returns a promise containing deserialized JSON:API data for the requested
   * object
   *
@@ -420,6 +425,7 @@ class DrupalState {
    params,
    all = false,
    refresh = false,
    anon = false,
    query = false,
  }: GetObjectParams): Promise<PartialState<State> | void> {
    if (query) {
@@ -512,7 +518,8 @@ class DrupalState {

      const resourceData = (await this.fetchData(
        endpoint,
        res
        res,
        anon
      )) as keyedResources;

      const objectResourceState = state[resourceKey];
@@ -572,7 +579,8 @@ class DrupalState {

      const collectionData = (await this.fetchData(
        endpoint,
        res
        res,
        anon
      )) as keyedResources;

      const fetchedCollectionState = {} as CollectionState;
+39 −11
Original line number Diff line number Diff line
@@ -348,7 +348,7 @@ describe('drupalState', () => {
    expect(fetchMock).toBeCalledTimes(2);
  });

  test('Re-fetch object if it exists in local storage but refresh is set to true', async () => {
  test('Re-fetch collection object if it exists in local state but refresh is set to true', async () => {
    const store: DrupalState = new DrupalState({
      apiBase: 'https://dev-ds-demo.pantheonsite.io',
      apiPrefix: 'jsonapi',
@@ -425,15 +425,8 @@ describe('drupalState', () => {
      clientSecret: 'mysecret',
      debug: true,
    });
    const getAuthHeaderSpy = jest.spyOn(store, 'getAuthHeader');
    store.setState({ dsApiIndex: indexResponse.links });
    fetchMock.mock(
      'https://dev-ds-demo.pantheonsite.io/en/jsonapi/node/recipe/33386d32-a87c-44b9-b66b-3dd0bfc38dca',
      {
        status: 200,
        body: recipesResourceData1,
      },
      { overwriteRoutes: true }
    );
    fetchMock.mock(
      {
        url: 'https://dev-ds-demo.pantheonsite.io/oauth/token',
@@ -447,8 +440,13 @@ describe('drupalState', () => {
        body: tokenResponse,
      }
    );
    expect(await store['getAuthHeader']()).toEqual(
      `${tokenResponse.token_type} ${tokenResponse.access_token}`
    fetchMock.mock(
      'https://dev-ds-demo.pantheonsite.io/en/jsonapi/node/recipe/33386d32-a87c-44b9-b66b-3dd0bfc38dca',
      {
        status: 200,
        body: recipesResourceData1,
      },
      { overwriteRoutes: true }
    );
    expect(
      await store.getObject({
@@ -456,9 +454,39 @@ describe('drupalState', () => {
        id: '33386d32-a87c-44b9-b66b-3dd0bfc38dca',
      })
    ).toEqual(recipesResourceObject1);
    expect(getAuthHeaderSpy).toHaveBeenCalledTimes(1);
    expect(fetchMock).toBeCalledTimes(2);
  });

  test('should fetch resource anonymously', async () => {
    const store: DrupalState = new DrupalState({
      apiBase: 'https://dev-ds-demo.pantheonsite.io',
      apiPrefix: 'jsonapi',
      clientId: '9adc9c69-fa3b-4c21-9cef-fbd345d1a269',
      clientSecret: 'mysecret',
      debug: true,
    });
    const getAuthHeaderSpy = jest.spyOn(store, 'getAuthHeader');
    store.setState({ dsApiIndex: indexResponse.links });
    fetchMock.mock(
      'https://dev-ds-demo.pantheonsite.io/en/jsonapi/node/recipe/33386d32-a87c-44b9-b66b-3dd0bfc38dca',
      {
        status: 200,
        body: recipesResourceData1,
      },
      { overwriteRoutes: true }
    );
    expect(getAuthHeaderSpy).toHaveBeenCalledTimes(0);
    expect(
      await store.getObject({
        objectName: 'node--recipe',
        id: '33386d32-a87c-44b9-b66b-3dd0bfc38dca',
        anon: true,
      })
    ).toEqual(recipesResourceObject1);
    expect(fetchMock).toBeCalledTimes(1);
  });

  test('A locale is honored if specified', async () => {
    const store: DrupalState = new DrupalState({
      apiBase: 'https://demo-decoupled-bridge.lndo.site',
+46 −12
Original line number Diff line number Diff line
@@ -117,29 +117,63 @@ export interface jsonapiLinkObject {
  __typename: string;
}

interface SharedParams {
  /**
 * Describes get object parameters.
   * The name of the object in Drupal
   * @example 'node--article'
   */
export interface GetObjectParams {
  objectName: string;
  id?: string;
  /**
   * If included, the server response is passed to allow DrupalState to set headers
   * among other things.
   * @see {@link https://nodejs.org/docs/latest-v16.x/api/http.html#class-httpserverresponse}
   */
  res?: ServerResponse | boolean;
  /**
   * A string of Drupal JSON:API parameters.
   * @example 'include=field_media_image,
   * Or an instance of DrupalJsonApi Params.
   * @see {@link https://www.npmjs.com/package/drupal-jsonapi-params}
   */
  params?: string | DrupalJsonApiParams;
  /**
   * If true, data will be fetched from Drupal regardless of its existence in state
   */
  refresh?: boolean;
  /**
   * If true and valid credentials are passed in when creating the instance of
   * DrupalState, the request will be made anonymously
   */
  anon?: boolean;
  /**
   * @deprecated since 4.0.0
   */
  query?: string | boolean;
}
/**
 * Describes get object parameters.
 */
export interface GetObjectParams extends SharedParams {
  /**
   * The id of the object in Drupal
   */
  id?: string;
  /**
   * If true, DrupalState fetches all pages of an object if there is more than one.
   */
  all?: boolean;
  refresh?: boolean;
}

/**
 * Describes get object by Path alias.
 */
export interface GetObjectByPathParams {
  objectName: string;
export interface GetObjectByPathParams extends SharedParams {
  /**
   * The path to the object that Decoupled Router resolves to
   * @see {@link https://www.drupal.org/project/decoupled_router}
   * @example '/recipes/fiery-chili-sauce'
   */
  path: string;
  res?: ServerResponse | boolean;
  params?: string | DrupalJsonApiParams;
  query?: string | boolean;
  refresh?: boolean;
}

/**
+7 −0
Original line number Diff line number Diff line
@@ -70,6 +70,13 @@ const allArticlesFromApi = await store.getObject({
  objectName: 'node--ds_example',
  all: true,
});

// If your DrupalState store has valid credentials, all requests are authorized by default
// To make a request anonymous, use the `anon` option and set it to true
const anonRequest = await store.getObject({
  objectName: 'node--recipe',
  anon: true,
});
```

To better understand the advantages of Drupal State, below we will compare the