@ethlete/query 5.15.0 → 5.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +40 -0
- package/CHANGELOG.md +14 -0
- package/jest.config.ts +23 -0
- package/ng-package.json +8 -0
- package/package.json +6 -23
- package/project.json +37 -0
- package/{index.d.ts → src/index.ts} +2 -0
- package/src/lib/auth/auth-provider.types.ts +160 -0
- package/src/lib/auth/auth-provider.utils.ts +39 -0
- package/src/lib/auth/basic-auth-provider.ts +17 -0
- package/src/lib/auth/bearer-auth-provider.ts +243 -0
- package/src/lib/auth/custom-header-auth-provider.ts +13 -0
- package/{lib/class-validator/class-validator.types.d.ts → src/lib/class-validator/class-validator.types.ts} +4 -3
- package/src/lib/class-validator/class-validator.utils.ts +5 -0
- package/src/lib/devtools/components/query-devtools/query-devtools.component.html +402 -0
- package/src/lib/devtools/components/query-devtools/query-devtools.component.scss +360 -0
- package/src/lib/devtools/components/query-devtools/query-devtools.component.ts +427 -0
- package/src/lib/devtools/pipes/query-short-name.pipe.ts +24 -0
- package/src/lib/devtools/utils/query-client.utils.ts +25 -0
- package/src/lib/directives/infinity-query-trigger.directive.ts +58 -0
- package/src/lib/directives/infinity-query.directive.ts +208 -0
- package/src/lib/directives/query.directive.ts +201 -0
- package/src/lib/entity/entity-store.ts +180 -0
- package/src/lib/entity/entity.types.ts +34 -0
- package/src/lib/entity/entity.utils.ts +51 -0
- package/src/lib/gql/gql.ts +22 -0
- package/src/lib/infinite-query/infinity-query.ts +245 -0
- package/src/lib/infinite-query/infinity-query.types.ts +149 -0
- package/src/lib/infinite-query/infinity-query.utils.ts +14 -0
- package/src/lib/logger/logger.ts +30 -0
- package/src/lib/query/query.ts +631 -0
- package/src/lib/query/query.types.ts +532 -0
- package/src/lib/query/query.utils.spec.ts +92 -0
- package/src/lib/query/query.utils.ts +450 -0
- package/src/lib/query-client/query-client.spec.ts +77 -0
- package/src/lib/query-client/query-client.ts +195 -0
- package/src/lib/query-client/query-client.types.ts +101 -0
- package/src/lib/query-client/query-client.utils.spec.ts +61 -0
- package/src/lib/query-client/query-client.utils.ts +28 -0
- package/src/lib/query-creator/query-creator.ts +103 -0
- package/src/lib/query-creator/query-creator.types.ts +74 -0
- package/src/lib/query-form/query-form.ts +526 -0
- package/src/lib/query-form/query-form.types.ts +169 -0
- package/src/lib/query-form/query-form.utils.ts +116 -0
- package/src/lib/query-store/query-store.ts +192 -0
- package/src/lib/request/request.ts +253 -0
- package/src/lib/request/request.types.ts +164 -0
- package/src/lib/request/request.util.spec.ts +177 -0
- package/src/lib/request/request.util.ts +399 -0
- package/src/lib/stories/angular-example-page.mdx +85 -0
- package/src/lib/stories/authentication-page.mdx +35 -0
- package/src/lib/stories/changelog-page.mdx +9 -0
- package/src/lib/stories/client-page.mdx +100 -0
- package/src/lib/stories/queries-page.mdx +117 -0
- package/src/lib/symfony/symfony.types.ts +21 -0
- package/src/lib/symfony/symfony.utils.ts +43 -0
- package/src/lib/utils/data.utils.ts +280 -0
- package/src/lib/utils/deep-freeze.util.ts +21 -0
- package/{lib/utils/def.util.d.ts → src/lib/utils/def.util.ts} +1 -1
- package/src/test-setup.ts +1 -0
- package/tsconfig.json +29 -0
- package/tsconfig.lib.json +12 -0
- package/tsconfig.lib.prod.json +10 -0
- package/tsconfig.spec.json +11 -0
- package/esm2022/ethlete-query.mjs +0 -5
- package/esm2022/index.mjs +0 -20
- package/esm2022/lib/auth/auth-provider.types.mjs +0 -2
- package/esm2022/lib/auth/auth-provider.utils.mjs +0 -25
- package/esm2022/lib/auth/basic-auth-provider.mjs +0 -15
- package/esm2022/lib/auth/bearer-auth-provider.mjs +0 -168
- package/esm2022/lib/auth/custom-header-auth-provider.mjs +0 -12
- package/esm2022/lib/auth/index.mjs +0 -6
- package/esm2022/lib/class-validator/class-validator.types.mjs +0 -2
- package/esm2022/lib/class-validator/class-validator.utils.mjs +0 -4
- package/esm2022/lib/class-validator/index.mjs +0 -3
- package/esm2022/lib/devtools/components/query-devtools/index.mjs +0 -2
- package/esm2022/lib/devtools/components/query-devtools/query-devtools.component.mjs +0 -297
- package/esm2022/lib/devtools/pipes/index.mjs +0 -2
- package/esm2022/lib/devtools/pipes/query-short-name.pipe.mjs +0 -24
- package/esm2022/lib/devtools/utils/index.mjs +0 -2
- package/esm2022/lib/devtools/utils/query-client.utils.mjs +0 -13
- package/esm2022/lib/directives/index.mjs +0 -4
- package/esm2022/lib/directives/infinity-query-trigger.directive.mjs +0 -56
- package/esm2022/lib/directives/infinity-query.directive.mjs +0 -147
- package/esm2022/lib/directives/query.directive.mjs +0 -123
- package/esm2022/lib/entity/entity-store.mjs +0 -127
- package/esm2022/lib/entity/entity.types.mjs +0 -2
- package/esm2022/lib/entity/entity.utils.mjs +0 -29
- package/esm2022/lib/entity/index.mjs +0 -4
- package/esm2022/lib/gql/gql.mjs +0 -23
- package/esm2022/lib/gql/index.mjs +0 -2
- package/esm2022/lib/infinite-query/index.mjs +0 -4
- package/esm2022/lib/infinite-query/infinity-query.mjs +0 -171
- package/esm2022/lib/infinite-query/infinity-query.types.mjs +0 -2
- package/esm2022/lib/infinite-query/infinity-query.utils.mjs +0 -3
- package/esm2022/lib/logger/index.mjs +0 -2
- package/esm2022/lib/logger/logger.mjs +0 -16
- package/esm2022/lib/query/index.mjs +0 -4
- package/esm2022/lib/query/query.mjs +0 -428
- package/esm2022/lib/query/query.types.mjs +0 -2
- package/esm2022/lib/query/query.utils.mjs +0 -253
- package/esm2022/lib/query-client/index.mjs +0 -4
- package/esm2022/lib/query-client/query-client.mjs +0 -80
- package/esm2022/lib/query-client/query-client.types.mjs +0 -2
- package/esm2022/lib/query-client/query-client.utils.mjs +0 -20
- package/esm2022/lib/query-creator/index.mjs +0 -3
- package/esm2022/lib/query-creator/query-creator.mjs +0 -55
- package/esm2022/lib/query-creator/query-creator.types.mjs +0 -2
- package/esm2022/lib/query-form/index.mjs +0 -4
- package/esm2022/lib/query-form/query-form.mjs +0 -368
- package/esm2022/lib/query-form/query-form.types.mjs +0 -2
- package/esm2022/lib/query-form/query-form.utils.mjs +0 -97
- package/esm2022/lib/query-store/index.mjs +0 -2
- package/esm2022/lib/query-store/query-store.mjs +0 -144
- package/esm2022/lib/request/index.mjs +0 -4
- package/esm2022/lib/request/request.mjs +0 -196
- package/esm2022/lib/request/request.types.mjs +0 -2
- package/esm2022/lib/request/request.util.mjs +0 -252
- package/esm2022/lib/symfony/index.mjs +0 -3
- package/esm2022/lib/symfony/symfony.types.mjs +0 -2
- package/esm2022/lib/symfony/symfony.utils.mjs +0 -31
- package/esm2022/lib/utils/data.utils.mjs +0 -138
- package/esm2022/lib/utils/deep-freeze.util.mjs +0 -17
- package/esm2022/lib/utils/def.util.mjs +0 -8
- package/esm2022/lib/utils/index.mjs +0 -4
- package/fesm2022/ethlete-query.mjs +0 -3276
- package/fesm2022/ethlete-query.mjs.map +0 -1
- package/lib/auth/auth-provider.types.d.ts +0 -133
- package/lib/auth/auth-provider.utils.d.ts +0 -9
- package/lib/auth/basic-auth-provider.d.ts +0 -10
- package/lib/auth/bearer-auth-provider.d.ts +0 -23
- package/lib/auth/custom-header-auth-provider.d.ts +0 -9
- package/lib/class-validator/class-validator.utils.d.ts +0 -2
- package/lib/devtools/components/query-devtools/query-devtools.component.d.ts +0 -151
- package/lib/devtools/pipes/query-short-name.pipe.d.ts +0 -8
- package/lib/devtools/utils/query-client.utils.d.ts +0 -12
- package/lib/directives/infinity-query-trigger.directive.d.ts +0 -15
- package/lib/directives/infinity-query.directive.d.ts +0 -49
- package/lib/directives/query.directive.d.ts +0 -63
- package/lib/entity/entity-store.d.ts +0 -25
- package/lib/entity/entity.types.d.ts +0 -25
- package/lib/entity/entity.utils.d.ts +0 -12
- package/lib/gql/gql.d.ts +0 -6
- package/lib/infinite-query/infinity-query.d.ts +0 -29
- package/lib/infinite-query/infinity-query.types.d.ts +0 -104
- package/lib/infinite-query/infinity-query.utils.d.ts +0 -4
- package/lib/logger/logger.d.ts +0 -12
- package/lib/query/query.d.ts +0 -60
- package/lib/query/query.types.d.ts +0 -388
- package/lib/query/query.utils.d.ts +0 -91
- package/lib/query-client/query-client.d.ts +0 -29
- package/lib/query-client/query-client.types.d.ts +0 -82
- package/lib/query-client/query-client.utils.d.ts +0 -4
- package/lib/query-creator/query-creator.d.ts +0 -20
- package/lib/query-creator/query-creator.types.d.ts +0 -13
- package/lib/query-form/query-form.d.ts +0 -89
- package/lib/query-form/query-form.types.d.ts +0 -138
- package/lib/query-form/query-form.utils.d.ts +0 -13
- package/lib/query-store/query-store.d.ts +0 -31
- package/lib/request/request.d.ts +0 -3
- package/lib/request/request.types.d.ts +0 -126
- package/lib/request/request.util.d.ts +0 -92
- package/lib/symfony/symfony.types.d.ts +0 -19
- package/lib/symfony/symfony.utils.d.ts +0 -6
- package/lib/utils/data.utils.d.ts +0 -59
- package/lib/utils/deep-freeze.util.d.ts +0 -1
- /package/{lib/auth/index.d.ts → src/lib/auth/index.ts} +0 -0
- /package/{lib/class-validator/index.d.ts → src/lib/class-validator/index.ts} +0 -0
- /package/{lib/devtools/components/query-devtools/index.d.ts → src/lib/devtools/components/query-devtools/index.ts} +0 -0
- /package/{lib/devtools/pipes/index.d.ts → src/lib/devtools/pipes/index.ts} +0 -0
- /package/{lib/devtools/utils/index.d.ts → src/lib/devtools/utils/index.ts} +0 -0
- /package/{lib/directives/index.d.ts → src/lib/directives/index.ts} +0 -0
- /package/{lib/entity/index.d.ts → src/lib/entity/index.ts} +0 -0
- /package/{lib/gql/index.d.ts → src/lib/gql/index.ts} +0 -0
- /package/{lib/infinite-query/index.d.ts → src/lib/infinite-query/index.ts} +0 -0
- /package/{lib/logger/index.d.ts → src/lib/logger/index.ts} +0 -0
- /package/{lib/query/index.d.ts → src/lib/query/index.ts} +0 -0
- /package/{lib/query-client/index.d.ts → src/lib/query-client/index.ts} +0 -0
- /package/{lib/query-creator/index.d.ts → src/lib/query-creator/index.ts} +0 -0
- /package/{lib/query-form/index.d.ts → src/lib/query-form/index.ts} +0 -0
- /package/{lib/query-store/index.d.ts → src/lib/query-store/index.ts} +0 -0
- /package/{lib/request/index.d.ts → src/lib/request/index.ts} +0 -0
- /package/{lib/symfony/index.d.ts → src/lib/symfony/index.ts} +0 -0
- /package/{lib/utils/index.d.ts → src/lib/utils/index.ts} +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { invalidBaseRouteError, invalidRouteError, pathParamsMissingInRouteFunctionError } from '../logger';
|
|
2
|
+
import { RouteString } from '../query';
|
|
3
|
+
import { buildQueryString, buildRoute, isRequestError } from './request.util';
|
|
4
|
+
|
|
5
|
+
describe('isRequestError', () => {
|
|
6
|
+
it('should be falsy', () => {
|
|
7
|
+
expect(isRequestError('foo')).toBeFalsy();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should by truthy', () => {
|
|
11
|
+
expect(isRequestError({ status: 200, statusText: 'Error', url: '...' })).toBeTruthy();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('buildQueryString', () => {
|
|
16
|
+
const uriBrackets = encodeURIComponent('[]');
|
|
17
|
+
const dot = encodeURIComponent('.');
|
|
18
|
+
const uriBracketsZero = encodeURIComponent('[0]');
|
|
19
|
+
const brOpen = encodeURIComponent('[');
|
|
20
|
+
const brClose = encodeURIComponent(']');
|
|
21
|
+
const uriBracketsOne = encodeURIComponent('[1]');
|
|
22
|
+
const uriBracketsTwo = encodeURIComponent('[2]');
|
|
23
|
+
|
|
24
|
+
it('should work with a single param', () => {
|
|
25
|
+
expect(buildQueryString({ foo: 123 })).toEqual('foo=123');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should work with multiple params', () => {
|
|
29
|
+
expect(buildQueryString({ foo: 123, bar: 'string' })).toEqual('foo=123&bar=string');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should work with valid but falsy params', () => {
|
|
33
|
+
expect(
|
|
34
|
+
buildQueryString({
|
|
35
|
+
bar: 0,
|
|
36
|
+
stuff: -0,
|
|
37
|
+
}),
|
|
38
|
+
).toEqual('bar=0&stuff=0');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should work with arrays', () => {
|
|
42
|
+
expect(
|
|
43
|
+
buildQueryString({
|
|
44
|
+
foo: ['abc', 'def'],
|
|
45
|
+
bar: true,
|
|
46
|
+
}),
|
|
47
|
+
).toEqual(`foo${uriBrackets}=abc&foo${uriBrackets}=def&bar=true`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should filter out invalid values using default config', () => {
|
|
51
|
+
expect(
|
|
52
|
+
buildQueryString({
|
|
53
|
+
foo: ['abc', 'def', null, undefined, NaN],
|
|
54
|
+
bar: true,
|
|
55
|
+
baz: Infinity,
|
|
56
|
+
bi: ' ',
|
|
57
|
+
}),
|
|
58
|
+
).toEqual(`foo${uriBrackets}=abc&foo${uriBrackets}=def&bar=true`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should use correct filtered array indexes', () => {
|
|
62
|
+
expect(
|
|
63
|
+
buildQueryString(
|
|
64
|
+
{
|
|
65
|
+
foo: ['abc', null, 'def', NaN, Infinity, -Infinity, undefined, 'bar'],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
writeArrayIndexes: true,
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
).toEqual(`foo${uriBracketsZero}=abc&foo${uriBracketsOne}=def&foo${uriBracketsTwo}=bar`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should work with array params containing objects in dot notation', () => {
|
|
75
|
+
expect(
|
|
76
|
+
buildQueryString(
|
|
77
|
+
{
|
|
78
|
+
filters: [{ name: 'category', values: ['Stuff', 'Other'] }],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
objectNotation: 'dot',
|
|
82
|
+
writeArrayIndexes: true,
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
).toEqual(
|
|
86
|
+
`filters${uriBracketsZero}${dot}name=category&filters${uriBracketsZero}${dot}values${uriBracketsZero}=Stuff&filters${uriBracketsZero}${dot}values${uriBracketsOne}=Other`,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should work with array params containing objects in bracket notation', () => {
|
|
91
|
+
expect(
|
|
92
|
+
buildQueryString(
|
|
93
|
+
{
|
|
94
|
+
filters: [{ name: 'category', values: ['Stuff', 'Other'] }],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
objectNotation: 'bracket',
|
|
98
|
+
writeArrayIndexes: true,
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
).toEqual(
|
|
102
|
+
`filters${uriBracketsZero}${brOpen}name${brClose}=category&filters${uriBracketsZero}${brOpen}values${brClose}${uriBracketsZero}=Stuff&filters${uriBracketsZero}${brOpen}values${brClose}${uriBracketsOne}=Other`,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('buildRoute', () => {
|
|
108
|
+
it('should work without params', () => {
|
|
109
|
+
expect(
|
|
110
|
+
buildRoute({
|
|
111
|
+
base: 'https://example.com',
|
|
112
|
+
route: '/foo',
|
|
113
|
+
}),
|
|
114
|
+
).toBe('https://example.com/foo');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should work with path params', () => {
|
|
118
|
+
expect(
|
|
119
|
+
buildRoute({
|
|
120
|
+
base: 'https://example.com',
|
|
121
|
+
route: (args) => '/foo/' + args['id'],
|
|
122
|
+
pathParams: { id: 1 },
|
|
123
|
+
}),
|
|
124
|
+
).toBe('https://example.com/foo/1');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should work with query params', () => {
|
|
128
|
+
expect(
|
|
129
|
+
buildRoute({
|
|
130
|
+
base: 'https://example.com',
|
|
131
|
+
route: '/foo',
|
|
132
|
+
queryParams: { id: 1 },
|
|
133
|
+
}),
|
|
134
|
+
).toBe('https://example.com/foo?id=1');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should work with query and path params', () => {
|
|
138
|
+
expect(
|
|
139
|
+
buildRoute({
|
|
140
|
+
base: 'https://example.com',
|
|
141
|
+
route: (args) => '/foo/' + args['id'],
|
|
142
|
+
pathParams: { id: 1 },
|
|
143
|
+
queryParams: { filter: true },
|
|
144
|
+
}),
|
|
145
|
+
).toBe('https://example.com/foo/1?filter=true');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should fail with a route function without provided path params', () => {
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
+
const fn = (args: any) => '/foo/' + args.id;
|
|
151
|
+
|
|
152
|
+
expect(() => {
|
|
153
|
+
buildRoute({
|
|
154
|
+
base: 'https://example.com',
|
|
155
|
+
route: (args) => '/foo/' + args['id'],
|
|
156
|
+
});
|
|
157
|
+
}).toThrow(pathParamsMissingInRouteFunctionError(fn({})));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should fail with base route ending with a slash', () => {
|
|
161
|
+
expect(() => {
|
|
162
|
+
buildRoute({
|
|
163
|
+
base: 'https://example.com/',
|
|
164
|
+
route: '/foo',
|
|
165
|
+
});
|
|
166
|
+
}).toThrow(invalidBaseRouteError('https://example.com/'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should fail with route missing a slash at the start', () => {
|
|
170
|
+
expect(() => {
|
|
171
|
+
buildRoute({
|
|
172
|
+
base: 'https://example.com',
|
|
173
|
+
route: 'foo' as RouteString,
|
|
174
|
+
});
|
|
175
|
+
}).toThrow(invalidRouteError('foo'));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { invalidBaseRouteError, invalidRouteError, pathParamsMissingInRouteFunctionError } from '../logger';
|
|
2
|
+
import { AnyRoute } from '../query';
|
|
3
|
+
import { isSymfonyPagerfantaOutOfRangeError } from '../symfony';
|
|
4
|
+
import {
|
|
5
|
+
BuildQueryStringConfig,
|
|
6
|
+
Method,
|
|
7
|
+
PathParams,
|
|
8
|
+
QueryParams,
|
|
9
|
+
RequestError,
|
|
10
|
+
RequestHeaders,
|
|
11
|
+
RequestRetryFn,
|
|
12
|
+
} from './request.types';
|
|
13
|
+
|
|
14
|
+
export const isNaN = (value: unknown): boolean => typeof value === 'number' && Number.isNaN(value);
|
|
15
|
+
export const isEmptyString = (value: unknown): boolean => typeof value === 'string' && value.trim() === '';
|
|
16
|
+
|
|
17
|
+
export const isRequestError = <T = unknown>(error: unknown): error is RequestError<T> =>
|
|
18
|
+
error instanceof Object && 'status' in error && 'statusText' in error && 'url' in error;
|
|
19
|
+
|
|
20
|
+
export const buildRoute = (options: {
|
|
21
|
+
base: string;
|
|
22
|
+
route: AnyRoute | null | undefined;
|
|
23
|
+
pathParams?: PathParams;
|
|
24
|
+
queryParams?: QueryParams;
|
|
25
|
+
queryParamConfig?: BuildQueryStringConfig;
|
|
26
|
+
}) => {
|
|
27
|
+
if (options.base.endsWith('/')) {
|
|
28
|
+
throw invalidBaseRouteError(options.base);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let route: string | null = null;
|
|
32
|
+
|
|
33
|
+
if (typeof options.route === 'function') {
|
|
34
|
+
if (!options.pathParams) {
|
|
35
|
+
throw pathParamsMissingInRouteFunctionError(options.route({}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
route = options.route(options.pathParams);
|
|
39
|
+
} else {
|
|
40
|
+
route = options.route ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (route && !route.startsWith('/')) {
|
|
44
|
+
throw invalidRouteError(route);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.queryParams) {
|
|
48
|
+
const queryString = buildQueryString(options.queryParams, options.queryParamConfig);
|
|
49
|
+
|
|
50
|
+
if (queryString) {
|
|
51
|
+
route = route ? `${route}?${queryString}` : `/?${queryString}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return `${options.base}${route ?? ''}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const buildQueryString = (params: QueryParams, config?: BuildQueryStringConfig): string | null => {
|
|
59
|
+
const objectNotation = config?.objectNotation ?? 'bracket';
|
|
60
|
+
const writeArrayIndexes = config?.writeArrayIndexes ?? false;
|
|
61
|
+
const ignoredValues = config?.ignoredValues ?? [undefined, null, Infinity, -Infinity];
|
|
62
|
+
const ignoredValuesFns = config?.ignoredValuesFns ?? [isNaN, isEmptyString];
|
|
63
|
+
|
|
64
|
+
const queryParams: string[] = [];
|
|
65
|
+
|
|
66
|
+
function processValue(key: string, value: unknown) {
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
let currentFilteredIndex = 0;
|
|
69
|
+
for (const arrayValue of value) {
|
|
70
|
+
const nestedKey = writeArrayIndexes ? `${key}[${currentFilteredIndex}]` : `${key}[]`;
|
|
71
|
+
|
|
72
|
+
const didAddValue = processValue(nestedKey, arrayValue);
|
|
73
|
+
|
|
74
|
+
if (didAddValue) {
|
|
75
|
+
currentFilteredIndex++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
81
|
+
for (const [objKey, val] of Object.entries(value)) {
|
|
82
|
+
const nestedKey = objectNotation === 'dot' ? `${key}.${objKey}` : `${key}[${objKey}]`;
|
|
83
|
+
processValue(nestedKey, val);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
} else {
|
|
88
|
+
if (ignoredValues.includes(value)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ignoredValuesFns.some((fn) => fn(value))) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const encodedKey = encodeURIComponent(key);
|
|
97
|
+
const encodedValue = encodeURIComponent(value as string);
|
|
98
|
+
|
|
99
|
+
queryParams.push(`${encodedKey}=${encodedValue}`);
|
|
100
|
+
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const [key, val] of Object.entries(params)) {
|
|
106
|
+
processValue(key, val);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return queryParams.length ? queryParams.join('&') : null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const extractExpiresInSeconds = (headers: RequestHeaders) => {
|
|
113
|
+
const cacheControl = headers['cache-control'];
|
|
114
|
+
const age = headers['age'];
|
|
115
|
+
const expires = headers['expires'];
|
|
116
|
+
|
|
117
|
+
// In seconds
|
|
118
|
+
let expiresIn: number | null = null;
|
|
119
|
+
let maxAge: number | null = null;
|
|
120
|
+
|
|
121
|
+
if (cacheControl?.includes('no-cache')) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (cacheControl?.includes('max-age')) {
|
|
126
|
+
const m = cacheControl.split('max-age=')[1];
|
|
127
|
+
|
|
128
|
+
if (m) {
|
|
129
|
+
maxAge = parseInt(m);
|
|
130
|
+
}
|
|
131
|
+
} else if (cacheControl?.includes('s-maxage')) {
|
|
132
|
+
const m = cacheControl.split('s-maxage=')[1];
|
|
133
|
+
|
|
134
|
+
if (m) {
|
|
135
|
+
maxAge = parseInt(m);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (maxAge && age) {
|
|
140
|
+
const ageSeconds = parseInt(age);
|
|
141
|
+
|
|
142
|
+
expiresIn = maxAge - ageSeconds;
|
|
143
|
+
} else if (maxAge) {
|
|
144
|
+
expiresIn = maxAge / 2; // We assume the response is half way to its expiration
|
|
145
|
+
} else if (expires) {
|
|
146
|
+
// Used by some apis to tell the response will never expire
|
|
147
|
+
// In this case we let the response expire after 1 hour
|
|
148
|
+
if (expires === '-1') {
|
|
149
|
+
expiresIn = 3600;
|
|
150
|
+
} else {
|
|
151
|
+
const expiresDate = new Date(expires);
|
|
152
|
+
|
|
153
|
+
// check if the date is valid
|
|
154
|
+
if (expiresDate.toString() !== 'Invalid Date') {
|
|
155
|
+
expiresIn = Math.floor((expiresDate.getTime() - Date.now()) / 1000);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return expiresIn;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const buildTimestampFromSeconds = (seconds: number | null) => {
|
|
164
|
+
if (seconds === null) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return new Date(Date.now() + seconds * 1000).getTime();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const serializeBody = (body: unknown): ArrayBuffer | URLSearchParams | Blob | FormData | string | null => {
|
|
172
|
+
if (body === null || body === undefined) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
body instanceof ArrayBuffer ||
|
|
178
|
+
body instanceof Blob ||
|
|
179
|
+
body instanceof FormData ||
|
|
180
|
+
body instanceof URLSearchParams ||
|
|
181
|
+
typeof body === 'string'
|
|
182
|
+
) {
|
|
183
|
+
return body as ArrayBuffer | URLSearchParams | Blob | FormData | string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (typeof body === 'object' || typeof body === 'boolean' || Array.isArray(body)) {
|
|
187
|
+
return JSON.stringify(body);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
return (body as any).toString();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const transformMethod = (config: { method: Method; transferVia?: 'GET' | 'POST' }) => {
|
|
195
|
+
if (config.method === 'GQL_QUERY' || config.method === 'GQL_MUTATE') {
|
|
196
|
+
if (!config.transferVia) {
|
|
197
|
+
return 'POST';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return config.transferVia;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return config.method;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const detectContentTypeHeader = (body: unknown) => {
|
|
207
|
+
// An empty body has no content type.
|
|
208
|
+
if (body === null) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
// FormData bodies rely on the browser's content type assignment.
|
|
212
|
+
if (body instanceof FormData) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
// Blobs usually have their own content type. If it doesn't, then
|
|
216
|
+
// no type can be inferred.
|
|
217
|
+
if (body instanceof Blob) {
|
|
218
|
+
return body.type || null;
|
|
219
|
+
}
|
|
220
|
+
// Array buffers have unknown contents and thus no type can be inferred.
|
|
221
|
+
if (body instanceof ArrayBuffer) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
// Technically, strings could be a form of JSON data, but it's safe enough
|
|
225
|
+
// to assume they're plain strings.
|
|
226
|
+
if (typeof body === 'string') {
|
|
227
|
+
return 'text/plain';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Arrays, objects, boolean and numbers will be encoded as JSON.
|
|
231
|
+
if (typeof body === 'object' || typeof body === 'number' || typeof body === 'boolean') {
|
|
232
|
+
return 'application/json';
|
|
233
|
+
}
|
|
234
|
+
// No type could be inferred.
|
|
235
|
+
return null;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const hasHeader = (headers: RequestHeaders, header: string) => {
|
|
239
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === header.toLowerCase());
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const forEachHeader = (headers: RequestHeaders, callback: (value: string, key: string) => void) => {
|
|
243
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
244
|
+
callback(key, value);
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export const parseAllXhrResponseHeaders = (xhr: XMLHttpRequest) => {
|
|
249
|
+
const headers = xhr.getAllResponseHeaders();
|
|
250
|
+
const parsedHeaders: RequestHeaders = {};
|
|
251
|
+
|
|
252
|
+
for (const line of headers.split('\n')) {
|
|
253
|
+
const index = line.indexOf(':');
|
|
254
|
+
if (index > 0) {
|
|
255
|
+
const name = line.slice(0, index);
|
|
256
|
+
const key = name.toLowerCase();
|
|
257
|
+
const value = line.slice(index + 1).trim();
|
|
258
|
+
|
|
259
|
+
parsedHeaders[key] = value;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return parsedHeaders;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const getResponseUrl = (xhr: XMLHttpRequest): string | null => {
|
|
267
|
+
if ('responseURL' in xhr && xhr.responseURL) {
|
|
268
|
+
return xhr.responseURL;
|
|
269
|
+
}
|
|
270
|
+
if (/^X-Request-URL:/m.test(xhr.getAllResponseHeaders())) {
|
|
271
|
+
return xhr.getResponseHeader('X-Request-URL');
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const shouldRetryRequest: RequestRetryFn = (config) => {
|
|
277
|
+
const defaultRetryDelay = 1000 + 1000 * config.currentRetryCount;
|
|
278
|
+
|
|
279
|
+
if (config.currentRetryCount > 3) {
|
|
280
|
+
return { retry: false };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!isRequestError(config.error)) {
|
|
284
|
+
return { retry: false };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { status, detail } = config.error;
|
|
288
|
+
|
|
289
|
+
// Retry on 5xx errors
|
|
290
|
+
if (status >= 500) {
|
|
291
|
+
// Don't retry if a requested page is out of range
|
|
292
|
+
if (isSymfonyPagerfantaOutOfRangeError(detail)) {
|
|
293
|
+
return { retry: false };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { retry: true, delay: defaultRetryDelay };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Retry on 408 or 425 errors
|
|
300
|
+
if (status === HttpStatusCode.RequestTimeout || status === HttpStatusCode.TooEarly) {
|
|
301
|
+
return { retry: true, delay: defaultRetryDelay };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Retry on 429 errors
|
|
305
|
+
if (status === HttpStatusCode.TooManyRequests) {
|
|
306
|
+
const retryAfter =
|
|
307
|
+
config.headers['retry-after'] ||
|
|
308
|
+
config.headers['Retry-After'] ||
|
|
309
|
+
config.headers['x-retry-after'] ||
|
|
310
|
+
config.headers['X-Retry-After'];
|
|
311
|
+
|
|
312
|
+
if (retryAfter) {
|
|
313
|
+
const delay = parseInt(retryAfter) * 1000;
|
|
314
|
+
return { retry: true, delay: Number.isNaN(delay) ? defaultRetryDelay : delay };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { retry: true, delay: defaultRetryDelay };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Code 0 usually means the internet connection is down. We retry in this case.
|
|
321
|
+
// It could also be a CORS issue but that should not be the case in production.
|
|
322
|
+
if (status === 0) {
|
|
323
|
+
return { retry: true, delay: defaultRetryDelay };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { retry: false };
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export const enum HttpStatusCode {
|
|
330
|
+
Unknown = 0,
|
|
331
|
+
|
|
332
|
+
Continue = 100,
|
|
333
|
+
SwitchingProtocols = 101,
|
|
334
|
+
Processing = 102,
|
|
335
|
+
EarlyHints = 103,
|
|
336
|
+
|
|
337
|
+
Ok = 200,
|
|
338
|
+
Created = 201,
|
|
339
|
+
Accepted = 202,
|
|
340
|
+
NonAuthoritativeInformation = 203,
|
|
341
|
+
NoContent = 204,
|
|
342
|
+
ResetContent = 205,
|
|
343
|
+
PartialContent = 206,
|
|
344
|
+
MultiStatus = 207,
|
|
345
|
+
AlreadyReported = 208,
|
|
346
|
+
ImUsed = 226,
|
|
347
|
+
|
|
348
|
+
MultipleChoices = 300,
|
|
349
|
+
MovedPermanently = 301,
|
|
350
|
+
Found = 302,
|
|
351
|
+
SeeOther = 303,
|
|
352
|
+
NotModified = 304,
|
|
353
|
+
UseProxy = 305,
|
|
354
|
+
Unused = 306,
|
|
355
|
+
TemporaryRedirect = 307,
|
|
356
|
+
PermanentRedirect = 308,
|
|
357
|
+
|
|
358
|
+
BadRequest = 400,
|
|
359
|
+
Unauthorized = 401,
|
|
360
|
+
PaymentRequired = 402,
|
|
361
|
+
Forbidden = 403,
|
|
362
|
+
NotFound = 404,
|
|
363
|
+
MethodNotAllowed = 405,
|
|
364
|
+
NotAcceptable = 406,
|
|
365
|
+
ProxyAuthenticationRequired = 407,
|
|
366
|
+
RequestTimeout = 408,
|
|
367
|
+
Conflict = 409,
|
|
368
|
+
Gone = 410,
|
|
369
|
+
LengthRequired = 411,
|
|
370
|
+
PreconditionFailed = 412,
|
|
371
|
+
PayloadTooLarge = 413,
|
|
372
|
+
UriTooLong = 414,
|
|
373
|
+
UnsupportedMediaType = 415,
|
|
374
|
+
RangeNotSatisfiable = 416,
|
|
375
|
+
ExpectationFailed = 417,
|
|
376
|
+
ImATeapot = 418,
|
|
377
|
+
MisdirectedRequest = 421,
|
|
378
|
+
UnprocessableEntity = 422,
|
|
379
|
+
Locked = 423,
|
|
380
|
+
FailedDependency = 424,
|
|
381
|
+
TooEarly = 425,
|
|
382
|
+
UpgradeRequired = 426,
|
|
383
|
+
PreconditionRequired = 428,
|
|
384
|
+
TooManyRequests = 429,
|
|
385
|
+
RequestHeaderFieldsTooLarge = 431,
|
|
386
|
+
UnavailableForLegalReasons = 451,
|
|
387
|
+
|
|
388
|
+
InternalServerError = 500,
|
|
389
|
+
NotImplemented = 501,
|
|
390
|
+
BadGateway = 502,
|
|
391
|
+
ServiceUnavailable = 503,
|
|
392
|
+
GatewayTimeout = 504,
|
|
393
|
+
HttpVersionNotSupported = 505,
|
|
394
|
+
VariantAlsoNegotiates = 506,
|
|
395
|
+
InsufficientStorage = 507,
|
|
396
|
+
LoopDetected = 508,
|
|
397
|
+
NotExtended = 510,
|
|
398
|
+
NetworkAuthenticationRequired = 511,
|
|
399
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Meta } from '@storybook/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="HttpClient/Angular Usage Example" />
|
|
4
|
+
|
|
5
|
+
# Example
|
|
6
|
+
|
|
7
|
+
In Angular there are some helper tools that make it very easy to use the queries in Angular.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// product.queries.ts
|
|
11
|
+
|
|
12
|
+
export interface GetProductsArgs {
|
|
13
|
+
queryParams: {
|
|
14
|
+
search?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProductView {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const getProducts = client.get({
|
|
24
|
+
route: '/products',
|
|
25
|
+
secure: true,
|
|
26
|
+
types: {
|
|
27
|
+
args: def<GetProductsArgs>(),
|
|
28
|
+
response: def<ProductView[]>(),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// product-list.component.ts
|
|
35
|
+
import { getProducts } from './product.queries';
|
|
36
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, effect } from '@angular/core';
|
|
37
|
+
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
|
38
|
+
import { QueryDirective } from '@ethlete/query';
|
|
39
|
+
import { controlValueSignal } from '@ethlete/core';
|
|
40
|
+
|
|
41
|
+
@Component({
|
|
42
|
+
selector: 'app-product-list',
|
|
43
|
+
standalone: true,
|
|
44
|
+
imports: [QueryDirective, ReactiveFormsModule],
|
|
45
|
+
templateUrl: './product-list.component.html',
|
|
46
|
+
encapsulation: ViewEncapsulation.None,
|
|
47
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
48
|
+
})
|
|
49
|
+
export class ProductListComponent {
|
|
50
|
+
getProductsQuery = getProducts.createSignal();
|
|
51
|
+
search = new FormControl<null | string>(null);
|
|
52
|
+
searchChanges = controlValueSignal(this.search);
|
|
53
|
+
|
|
54
|
+
constructor() {
|
|
55
|
+
effect(() => {
|
|
56
|
+
const search = this.searchChanges();
|
|
57
|
+
this.getProductsQuery.set(
|
|
58
|
+
getProductsQuery
|
|
59
|
+
.prepare({
|
|
60
|
+
queryParams: {
|
|
61
|
+
search,
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
.execute(),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<!-- product-list.component.html -->
|
|
73
|
+
|
|
74
|
+
<input [formControl]="search" />
|
|
75
|
+
|
|
76
|
+
<div *etQuery="getProductsQuery() as response; loading as loading; error as error">
|
|
77
|
+
@for (product of response; track product.id) {
|
|
78
|
+
<div>{{ product.name }}</div>
|
|
79
|
+
} @if(loading) {
|
|
80
|
+
<p>Loading Template</p>
|
|
81
|
+
} @if(error && !loading) {
|
|
82
|
+
<p>Error Template</p>
|
|
83
|
+
}
|
|
84
|
+
</div>
|
|
85
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Meta } from '@storybook/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="HttpClient/Authentication" />
|
|
4
|
+
|
|
5
|
+
# Authentication
|
|
6
|
+
|
|
7
|
+
The client offers you the option of using authentication. There are various AuthProviders that you can easily configure and transfer to the client.
|
|
8
|
+
|
|
9
|
+
## BearerAuthProvider
|
|
10
|
+
|
|
11
|
+
The BearerAuthProvider is an AuthProvider that works with a Bearer Token. Once you have received the access token after logging in, you can easily pass it to the BearerAuthProvider. The BearerAuthProvider then refreshes the token once it has expired.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { QueryClient } from '@ethlete/query';
|
|
15
|
+
|
|
16
|
+
export const client = new QueryClient({
|
|
17
|
+
baseRoute: 'https://api.test.com',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const tokenFromLogin = 'token';
|
|
21
|
+
|
|
22
|
+
const bearerAuthProvider = new BearerAuthProvider({
|
|
23
|
+
token: tokenFromLogin,
|
|
24
|
+
refreshConfig: {
|
|
25
|
+
queryCreator: postRefreshToken,
|
|
26
|
+
token: refreshToken,
|
|
27
|
+
cookieName: 'auth-token',
|
|
28
|
+
cookieEnabled: true,
|
|
29
|
+
responseAdapter: (x) => ({ token: x.token, refreshToken: x.refresh_token }),
|
|
30
|
+
requestArgsAdapter: (token) => ({ body: { refresh_token: token.refreshToken } }),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
client.setAuthProvider(bearerAuthProvider);
|
|
35
|
+
```
|