Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

admin-graphql-api-utilities: Improve GID regex and add parseGidObject #2647

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thick-forks-end.md
@@ -0,0 +1,5 @@
---
'@shopify/admin-graphql-api-utilities': minor
---

Improved the GID regex to be more robust and prevent invalid GIDs (e.g. 'gid://-_-/_-_/-_-', 'Customers/123', etc.).
13 changes: 13 additions & 0 deletions packages/admin-graphql-api-utilities/README.md
Expand Up @@ -27,6 +27,19 @@ parseGidType('gid://shopify/Customer/12345');
// → 'Customer'
```

### `parseGidObject(gid: string): GidObject`

Given a Gid string, parse the components into an object.

#### Example Usage

```typescript
import {parseGidObject} from '@shopify/admin-graphql-api-utilities';

parseGidObject('gid://shopify/Customer/12345');
// → {namespace: 'shopify', type: 'Customer', id: '12345'}
```

### `function parseGid(gid: string): string`

Given a Gid string, parse out the id.
Expand Down
91 changes: 52 additions & 39 deletions packages/admin-graphql-api-utilities/src/index.ts
@@ -1,5 +1,14 @@
const GID_TYPE_REGEXP = /^gid:\/\/[\w-]+\/([\w-]+)\//;
const GID_REGEXP = /\/(\w[\w-]*)(?:\?(.*))*$/;
/**
* Matches a GID and captures the following groups:
* 1. The namespace
* 2. The type
* 3. The ID
* 4. The query string (if any)
*
* @see https://regex101.com/r/5j5AXK
*/
const GID_REGEX =
/^gid:\/\/([a-zA-Z][a-zA-Z0-9-]*)\/([a-zA-Z][\w-]*)\/(\w[\w-]*)(\?.*)?$/;

export type Gid<
Namespace extends string,
Expand All @@ -13,41 +22,50 @@ interface ParsedGid {
params: {[key: string]: string};
}

export function parseGidType(gid: string): string {
const matches = GID_TYPE_REGEXP.exec(gid);
export interface GidObject {
namespace: string;
type: string;
id: string;
queryString?: string;
}

if (matches && matches[1] !== undefined) {
return matches[1];
/**
* Attempts to parse a string into a GID object.
*
* @throws {Error} If the string is not a valid GID.
*/
export function parseGidObject(gid: string): GidObject {
const matches = GID_REGEX.exec(gid);

if (matches) {
return {
namespace: matches[1],
type: matches[2],
id: matches[3],
queryString: matches[4],
};
}

throw new Error(`Invalid gid: ${gid}`);
}

export function parseGidType(gid: string): string {
return parseGidObject(gid).type;
}

export function parseGid(gid: string): string {
// prepends forward slash to help identify invalid id
const id = `/${gid}`;
const matches = GID_REGEXP.exec(id);
if (matches && matches[1] !== undefined) {
return matches[1];
}
throw new Error(`Invalid gid: ${gid}`);
return parseGidObject(gid).id;
}

export function parseGidWithParams(gid: string): ParsedGid {
// prepends forward slash to help identify invalid id
const id = `/${gid}`;
const matches = GID_REGEXP.exec(id);
if (matches && matches[1] !== undefined) {
const params =
matches[2] === undefined
const obj = parseGidObject(gid);
return {
id: obj.id,
params:
obj.queryString === undefined
? {}
: fromEntries(new URLSearchParams(matches[2]).entries());

return {
id: matches[1],
params,
};
}
throw new Error(`Invalid gid: ${gid}`);
: fromEntries(new URLSearchParams(obj.queryString).entries()),
};
}

export function composeGidFactory<N extends string>(namespace: N) {
Expand All @@ -73,23 +91,18 @@ export const composeGid = composeGidFactory('shopify');
export function isGidFactory<N extends string>(namespace: N) {
return function isGid<T extends string = string>(
gid: string,
key?: T,
type?: T,
): gid is Gid<N, T> {
if (!gid.startsWith(`gid://${namespace}/`)) {
return false;
}

try {
if (key !== undefined && parseGidType(gid) !== key) {
return false;
}
const obj = parseGidObject(gid);
return (
obj.namespace === namespace &&
(type === undefined || obj.type === type) &&
obj.id.length > 0
);
} catch {
return false;
}

// prepends forward slash to help identify invalid id
const id = `/${gid}`;
return GID_REGEXP.test(id);
};
}

Expand Down
78 changes: 74 additions & 4 deletions packages/admin-graphql-api-utilities/src/tests/index.test.ts
Expand Up @@ -11,9 +11,82 @@ import {
keyFromEdges,
isGid,
isGidFactory,
parseGidObject,
} from '..';

describe('admin-graphql-api-utilities', () => {
describe('parseGidObject()', () => {
it('parses a standard GID', () => {
expect(parseGidObject('gid://shopify/Collection/123')).toStrictEqual({
namespace: 'shopify',
type: 'Collection',
id: '123',
queryString: undefined,
});
});

it('parses a GID with a non-numeric ID', () => {
expect(parseGidObject('gid://shopify/Collection/foo')).toStrictEqual({
namespace: 'shopify',
type: 'Collection',
id: 'foo',
queryString: undefined,
});
});

it('parses a GID with single-digit components', () => {
expect(parseGidObject('gid://s/C/1')).toStrictEqual({
namespace: 's',
type: 'C',
id: '1',
queryString: undefined,
});
});

it('parses a standard GID with params', () => {
expect(
parseGidObject(
'gid://shopify/Collection/123?title=hello%sworld&tags=large+blue',
),
).toStrictEqual({
namespace: 'shopify',
type: 'Collection',
id: '123',
queryString: '?title=hello%sworld&tags=large+blue',
});
});

it('throws on GID with extraneous components', () => {
const gid = 'gid://shopify/A/B/C/D/E/F/G/123';
expect(() => parseGidObject(gid)).toThrow(`Invalid gid: ${gid}`);
});

it('throws on GID with missing namespace', () => {
const gid = 'gid:///Collection/123';
expect(() => parseGidObject(gid)).toThrow(`Invalid gid: ${gid}`);
});

it('throws on GID with missing prefix', () => {
const gid = '//shopify/Collection/123';
expect(() => parseGidObject(gid)).toThrow(`Invalid gid: ${gid}`);
});

it('throws on GID with invalid prefix', () => {
const gid = '@#$%^&^*()/Foo/123';
expect(() => parseGidObject(gid)).toThrow(`Invalid gid: ${gid}`);
});

it('throws on GID with spaces', () => {
const gid = 'gid://shopify/Some Collection/123';
expect(() => parseGidObject(gid)).toThrow(`Invalid gid: ${gid}`);
});

it('throws on GID with invalid identifiers', () => {
const gid = 'gid://-_-_-_-/--------/__________';
expect(() => parseGidObject(gid)).toThrow(`Invalid gid: ${gid}`);
});
});

describe('parseGidType()', () => {
it('returns the type from a GID without param', () => {
const parsedType = parseGidType(
Expand Down Expand Up @@ -42,10 +115,6 @@ describe('admin-graphql-api-utilities', () => {
);
});

it('returns the id portion of an unprefixed gid', () => {
['1', '1a', v4()].forEach((id) => expect(parseGid(id)).toBe(id));
});

it('returns the id portion of a gid for integer ids', () => {
const id = '12';
const gid = `gid://shopify/Section/${id}`;
Expand Down Expand Up @@ -175,6 +244,7 @@ describe('admin-graphql-api-utilities', () => {
expect(isGid('gid:/shopify/Section/123')).toBe(false);
expect(isGid('//shopify/Section/123')).toBe(false);
expect(isGid('gid://shopify/Section/123 456')).toBe(false);
expect(isGid('123')).toBe(false);
});
});

Expand Down