@obsidiane/meridiane 0.0.6 → 0.0.7
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/README.md +42 -2
- package/cli.js +27 -1
- package/models.config.example.js +15 -0
- package/package.json +1 -3
- package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +48 -47
- package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +29 -29
- package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +50 -54
- package/templates/_lib_template/src/lib/bridge/sse/mercure-topic.mapper.ts +48 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +7 -7
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +104 -49
- package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +23 -21
- package/templates/_lib_template/src/lib/bridge.types.ts +33 -0
- package/templates/_lib_template/src/lib/facades/bridge.facade.ts +116 -0
- package/templates/_lib_template/src/lib/facades/facade.factory.ts +21 -19
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +13 -18
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +42 -56
- package/templates/_lib_template/src/lib/interceptors/bridge-debug.interceptor.ts +32 -0
- package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +53 -0
- package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +36 -37
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +5 -3
- package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +14 -15
- package/templates/_lib_template/src/lib/provide-bridge.ts +122 -19
- package/templates/_lib_template/src/lib/tokens.ts +14 -1
- package/templates/_lib_template/src/lib/utils/url.ts +15 -0
- package/templates/_lib_template/src/public-api.ts +3 -2
- package/tools/dev-bridge.js +138 -0
- package/tools/generate-lib.js +60 -45
- package/tools/generate-models.js +73 -19
- package/tools/generator/models/openapi-to-models.js +13 -2
- package/tools/generator/models/schema-utils.js +59 -0
- package/tools/generator/models/utils.js +8 -2
- package/tools/init.js +148 -0
- package/tools/sandbox-bridge.js +77 -0
- package/tools/utils/cli-args.js +41 -0
- package/tools/utils/dotenv.js +55 -0
- package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +0 -9
- package/templates/_lib_template/src/lib/utils/utils.ts +0 -21
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import {Signal} from '@angular/core';
|
|
2
2
|
import {
|
|
3
3
|
ResourceRepository,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
AnyQuery,
|
|
5
|
+
HttpCallOptions,
|
|
6
|
+
Iri,
|
|
7
|
+
IriRequired,
|
|
8
|
+
Collection,
|
|
9
|
+
Item,
|
|
10
|
+
HttpRequestConfig
|
|
8
11
|
} from '../ports/resource-repository.port';
|
|
9
12
|
import {RealtimePort, RealtimeStatus} from '../ports/realtime.port';
|
|
10
13
|
import {toSignal} from '@angular/core/rxjs-interop';
|
|
11
|
-
import {
|
|
12
|
-
import {Facade
|
|
14
|
+
import {filter, map, Observable, share} from 'rxjs';
|
|
15
|
+
import {Facade} from './facade.interface';
|
|
13
16
|
|
|
14
17
|
export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
15
18
|
|
|
@@ -17,99 +20,82 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
|
17
20
|
|
|
18
21
|
constructor(
|
|
19
22
|
protected readonly repo: ResourceRepository<T>,
|
|
20
|
-
protected readonly realtime: RealtimePort
|
|
21
|
-
protected readonly resourcePath: string
|
|
23
|
+
protected readonly realtime: RealtimePort
|
|
22
24
|
) {
|
|
23
|
-
this.resourcePath = resourcePath;
|
|
24
25
|
this.connectionStatus = toSignal(
|
|
25
26
|
this.realtime.status$(),
|
|
26
|
-
{initialValue: '
|
|
27
|
+
{initialValue: 'closed'}
|
|
27
28
|
);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
return this.repo.
|
|
31
|
+
getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>> {
|
|
32
|
+
return this.repo.getCollection$(query, opts);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
get$(iri:
|
|
35
|
-
return this.repo.get$(iri)
|
|
36
|
-
shareReplay({bufferSize: 1, refCount: true})
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
create$(cmd: CreateCommand<T>) {
|
|
41
|
-
return this.repo.create$(cmd).pipe(
|
|
42
|
-
shareReplay({bufferSize: 1, refCount: true})
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
update$(cmd: UpdateCommand<T>) {
|
|
47
|
-
return this.repo.update$(cmd).pipe(
|
|
48
|
-
shareReplay({bufferSize: 1, refCount: true})
|
|
49
|
-
);
|
|
35
|
+
get$(iri: IriRequired, opts?: HttpCallOptions): Observable<T> {
|
|
36
|
+
return this.repo.get$(iri, opts);
|
|
50
37
|
}
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
return this.repo.
|
|
54
|
-
shareReplay({bufferSize: 1, refCount: true})
|
|
55
|
-
);
|
|
39
|
+
patch$(iri: IriRequired, changes: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
40
|
+
return this.repo.patch$(iri, changes, opts);
|
|
56
41
|
}
|
|
57
42
|
|
|
58
|
-
|
|
59
|
-
return this.repo.
|
|
60
|
-
shareReplay({bufferSize: 1, refCount: true})
|
|
61
|
-
);
|
|
43
|
+
post$(payload: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
44
|
+
return this.repo.post$(payload, opts);
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
|
|
65
|
-
return this.
|
|
47
|
+
put$(iri: IriRequired, payload: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
48
|
+
return this.repo.put$(iri, payload, opts);
|
|
66
49
|
}
|
|
67
50
|
|
|
68
|
-
|
|
69
|
-
return this.
|
|
51
|
+
delete$(iri: IriRequired, opts?: HttpCallOptions): Observable<void> {
|
|
52
|
+
return this.repo.delete$(iri, opts);
|
|
70
53
|
}
|
|
71
54
|
|
|
72
|
-
|
|
73
|
-
return this.request$<R, B>(
|
|
55
|
+
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R> {
|
|
56
|
+
return this.repo.request$<R, B>(req);
|
|
74
57
|
}
|
|
75
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Subscribes to real-time updates for one or many IRIs.
|
|
61
|
+
* Undefined/empty values are ignored.
|
|
62
|
+
*/
|
|
76
63
|
watch$(iri: Iri | Iri[]): Observable<T> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
return this.subscribeAndSync([iri]);
|
|
64
|
+
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
65
|
+
return this.subscribeAndSync(iris);
|
|
81
66
|
}
|
|
82
67
|
|
|
83
68
|
|
|
84
69
|
unwatch(iri: Iri | Iri[]): void {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
this.realtime.unsubscribe([iri]);
|
|
70
|
+
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
71
|
+
this.realtime.unsubscribe(iris);
|
|
90
72
|
}
|
|
91
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Subscribes to updates of a related sub-resource published on the parent topic.
|
|
76
|
+
* Example: subscribe to Message events on a Conversation topic, filtering by `message.conversation`.
|
|
77
|
+
*/
|
|
92
78
|
watchSubResource$<R>(
|
|
93
79
|
iri: Iri | Iri[],
|
|
94
80
|
field: string
|
|
95
81
|
): Observable<R> {
|
|
96
|
-
const iris = Array.isArray(iri) ? iri : [iri];
|
|
82
|
+
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
97
83
|
return this.realtime
|
|
98
84
|
.subscribe$<R>(iris, {field: field})
|
|
99
85
|
.pipe(
|
|
100
86
|
map(e => e.data),
|
|
101
87
|
filter((d): d is R => d !== undefined),
|
|
102
|
-
|
|
88
|
+
share()
|
|
103
89
|
);
|
|
104
90
|
}
|
|
105
91
|
|
|
106
|
-
protected subscribeAndSync(iris:
|
|
92
|
+
protected subscribeAndSync(iris: string[]): Observable<T> {
|
|
107
93
|
return this.realtime
|
|
108
94
|
.subscribe$<T>(iris)
|
|
109
95
|
.pipe(
|
|
110
96
|
map(event => event.data),
|
|
111
97
|
filter((data): data is T => data !== undefined),
|
|
112
|
-
|
|
113
|
-
)
|
|
98
|
+
share()
|
|
99
|
+
);
|
|
114
100
|
}
|
|
115
101
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {HttpInterceptorFn, HttpResponse} from '@angular/common/http';
|
|
2
|
+
import {inject} from '@angular/core';
|
|
3
|
+
import {BRIDGE_LOGGER} from '../tokens';
|
|
4
|
+
import {catchError, finalize, tap, throwError} from 'rxjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight request/response logging controlled by `provideBridge({debug: true})`.
|
|
8
|
+
* Logs are delegated to the injected `BRIDGE_LOGGER`.
|
|
9
|
+
*/
|
|
10
|
+
export const bridgeDebugInterceptor: HttpInterceptorFn = (req, next) => {
|
|
11
|
+
const logger = inject(BRIDGE_LOGGER, {optional: true});
|
|
12
|
+
if (!logger) return next(req);
|
|
13
|
+
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
logger.debug('[Bridge] request', {method: req.method, url: req.urlWithParams});
|
|
16
|
+
|
|
17
|
+
return next(req).pipe(
|
|
18
|
+
tap((evt) => {
|
|
19
|
+
if (evt instanceof HttpResponse) {
|
|
20
|
+
logger.debug('[Bridge] response', {method: req.method, url: req.urlWithParams, status: evt.status});
|
|
21
|
+
}
|
|
22
|
+
}),
|
|
23
|
+
catchError((err) => {
|
|
24
|
+
logger.error('[Bridge] error', {method: req.method, url: req.urlWithParams, err});
|
|
25
|
+
return throwError(() => err);
|
|
26
|
+
}),
|
|
27
|
+
finalize(() => {
|
|
28
|
+
const durationMs = Date.now() - startedAt;
|
|
29
|
+
logger.debug('[Bridge] done', {method: req.method, url: req.urlWithParams, durationMs});
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {HttpInterceptorFn} from '@angular/common/http';
|
|
2
|
+
import {inject} from '@angular/core';
|
|
3
|
+
import {BRIDGE_DEFAULTS, BRIDGE_LOGGER} from '../tokens';
|
|
4
|
+
import {retry, timeout, timer} from 'rxjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RETRY_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Applies default headers, timeout and retry policy configured via `provideBridge({defaults: ...})`.
|
|
10
|
+
*/
|
|
11
|
+
export const bridgeDefaultsInterceptor: HttpInterceptorFn = (req, next) => {
|
|
12
|
+
const defaults = inject(BRIDGE_DEFAULTS, {optional: true}) ?? {};
|
|
13
|
+
const logger = inject(BRIDGE_LOGGER, {optional: true});
|
|
14
|
+
|
|
15
|
+
let nextReq = req;
|
|
16
|
+
|
|
17
|
+
if (defaults.headers) {
|
|
18
|
+
for (const [k, v] of Object.entries(defaults.headers)) {
|
|
19
|
+
if (!nextReq.headers.has(k)) {
|
|
20
|
+
nextReq = nextReq.clone({headers: nextReq.headers.set(k, v)});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let out$ = next(nextReq);
|
|
26
|
+
|
|
27
|
+
const timeoutMs = typeof defaults.timeoutMs === 'number' ? defaults.timeoutMs : undefined;
|
|
28
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
29
|
+
out$ = out$.pipe(timeout({first: timeoutMs}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const retryCfg = defaults.retries;
|
|
33
|
+
const retryCount = typeof retryCfg === 'number' ? retryCfg : retryCfg?.count;
|
|
34
|
+
if (retryCount && retryCount > 0) {
|
|
35
|
+
const methods = (typeof retryCfg === 'object' && retryCfg.methods) ? retryCfg.methods : DEFAULT_RETRY_METHODS;
|
|
36
|
+
const normalizedMethod = req.method.toUpperCase();
|
|
37
|
+
const methodAllowList = new Set(methods.map((m) => m.toUpperCase()));
|
|
38
|
+
if (methodAllowList.has(normalizedMethod)) {
|
|
39
|
+
const delayMs = typeof retryCfg === 'object' ? (retryCfg.delayMs ?? 250) : 250;
|
|
40
|
+
out$ = out$.pipe(
|
|
41
|
+
retry({
|
|
42
|
+
count: retryCount,
|
|
43
|
+
delay: (_err, retryIndex) => {
|
|
44
|
+
logger?.debug?.('[Bridge] retry', {url: req.urlWithParams, method: req.method, retryIndex});
|
|
45
|
+
return timer(delayMs);
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return out$;
|
|
53
|
+
};
|
|
@@ -1,50 +1,49 @@
|
|
|
1
1
|
import {HttpInterceptorFn} from '@angular/common/http';
|
|
2
2
|
|
|
3
3
|
export interface ApiMediaTypes {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
accept: string;
|
|
5
|
+
post: string;
|
|
6
|
+
put: string;
|
|
7
|
+
patch: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_MEDIA_TYPES: ApiMediaTypes = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
accept: 'application/ld+json',
|
|
12
|
+
post: 'application/ld+json',
|
|
13
|
+
put: 'application/ld+json',
|
|
14
|
+
patch: 'application/merge-patch+json',
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export const contentTypeInterceptor: HttpInterceptorFn = (req, next) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
const mediaTypes = DEFAULT_MEDIA_TYPES;
|
|
19
|
+
let headers = req.headers;
|
|
20
|
+
|
|
21
|
+
// Set Accept if missing.
|
|
22
|
+
if (!headers.has('Accept')) {
|
|
23
|
+
headers = headers.set('Accept', mediaTypes.accept);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Only methods with a body.
|
|
27
|
+
const isBodyMethod = ['POST', 'PUT', 'PATCH'].includes(req.method);
|
|
28
|
+
// Ignore FormData (browser sets multipart boundary).
|
|
29
|
+
const isFormData = typeof FormData !== 'undefined' && req.body instanceof FormData;
|
|
30
|
+
|
|
31
|
+
if (isBodyMethod && !isFormData && !headers.has('Content-Type')) {
|
|
32
|
+
let contentType: string;
|
|
33
|
+
|
|
34
|
+
switch (req.method) {
|
|
35
|
+
case 'PATCH':
|
|
36
|
+
contentType = mediaTypes.patch;
|
|
37
|
+
break;
|
|
38
|
+
case 'PUT':
|
|
39
|
+
contentType = mediaTypes.put;
|
|
40
|
+
break;
|
|
41
|
+
default:
|
|
42
|
+
contentType = mediaTypes.post;
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// Ignore FormData (Content-Type sera défini par le navigateur)
|
|
29
|
-
const isFormData = typeof FormData !== 'undefined' && req.body instanceof FormData;
|
|
30
|
-
|
|
31
|
-
if (isBodyMethod && !isFormData && !headers.has('Content-Type')) {
|
|
32
|
-
let contentType: string;
|
|
33
|
-
|
|
34
|
-
switch (req.method) {
|
|
35
|
-
case 'PATCH':
|
|
36
|
-
contentType = mediaTypes.patch;
|
|
37
|
-
break;
|
|
38
|
-
case 'PUT':
|
|
39
|
-
contentType = mediaTypes.put;
|
|
40
|
-
break;
|
|
41
|
-
default:
|
|
42
|
-
contentType = mediaTypes.post;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
headers = headers.set('Content-Type', contentType);
|
|
46
|
-
}
|
|
45
|
+
headers = headers.set('Content-Type', contentType);
|
|
46
|
+
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
return next(req.clone({headers}));
|
|
48
|
+
return next(req.clone({headers}));
|
|
50
49
|
};
|
|
@@ -11,10 +11,8 @@ export interface SseEvent {
|
|
|
11
11
|
|
|
12
12
|
export interface SseOptions {
|
|
13
13
|
withCredentials?: boolean;
|
|
14
|
-
listen?: string[];
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
16
|
export interface RealtimeEvent<T> {
|
|
19
17
|
iri: string;
|
|
20
18
|
data?: T;
|
|
@@ -24,8 +22,12 @@ export type SubscribeFilter = {
|
|
|
24
22
|
field: string;
|
|
25
23
|
};
|
|
26
24
|
|
|
27
|
-
|
|
28
25
|
export interface RealtimePort {
|
|
26
|
+
/**
|
|
27
|
+
* Subscribes to Mercure events for the given topics.
|
|
28
|
+
*
|
|
29
|
+
* Note: topics must be stable strings. Undefined values are ignored by the adapter.
|
|
30
|
+
*/
|
|
29
31
|
subscribe$<T>(iris: Iri[], filter?: SubscribeFilter): Observable<RealtimeEvent<T>>;
|
|
30
32
|
|
|
31
33
|
unsubscribe(iris: Iri[]): void;
|
|
@@ -2,6 +2,7 @@ import {HttpHeaders, HttpParams} from '@angular/common/http';
|
|
|
2
2
|
import {Observable} from 'rxjs';
|
|
3
3
|
|
|
4
4
|
export type Iri = string | undefined;
|
|
5
|
+
export type IriRequired = string;
|
|
5
6
|
|
|
6
7
|
type BaseHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';
|
|
7
8
|
export type HttpMethod = BaseHttpMethod | Lowercase<BaseHttpMethod>;
|
|
@@ -11,11 +12,11 @@ export type AnyQuery = Query | Record<string, QueryParamValue> | HttpParams;
|
|
|
11
12
|
|
|
12
13
|
export interface Item {
|
|
13
14
|
'@id'?: Iri;
|
|
14
|
-
'@context'?: string
|
|
15
|
-
'@type'?: string
|
|
15
|
+
'@context'?: string;
|
|
16
|
+
'@type'?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export interface View extends Item{
|
|
19
|
+
export interface View extends Item {
|
|
19
20
|
first?: Iri;
|
|
20
21
|
last?: Iri;
|
|
21
22
|
next?: Iri;
|
|
@@ -44,25 +45,23 @@ export interface Query {
|
|
|
44
45
|
filters?: Record<string, QueryParamValue>;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export interface
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export interface UpdateCommand<T> {
|
|
52
|
-
iri?: Iri;
|
|
53
|
-
changes: Partial<T>;
|
|
48
|
+
export interface HttpCallOptions {
|
|
49
|
+
headers?: HttpHeaders | Record<string, string>;
|
|
50
|
+
withCredentials?: boolean;
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
export interface ResourceRepository<T> {
|
|
57
|
-
|
|
54
|
+
getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>>;
|
|
55
|
+
|
|
56
|
+
get$(iri: IriRequired, opts?: HttpCallOptions): Observable<T>;
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
post$(payload: Partial<T>, opts?: HttpCallOptions): Observable<T>;
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
patch$(iri: IriRequired, changes: Partial<T>, opts?: HttpCallOptions): Observable<T>;
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
put$(iri: IriRequired, payload: Partial<T>, opts?: HttpCallOptions): Observable<T>;
|
|
64
63
|
|
|
65
|
-
delete$(iri:
|
|
64
|
+
delete$(iri: IriRequired, opts?: HttpCallOptions): Observable<void>;
|
|
66
65
|
|
|
67
66
|
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R>;
|
|
68
67
|
}
|
|
@@ -1,35 +1,138 @@
|
|
|
1
|
-
import {EnvironmentProviders,
|
|
2
|
-
import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
|
3
|
-
import {
|
|
1
|
+
import {EnvironmentProviders, makeEnvironmentProviders} from '@angular/core';
|
|
2
|
+
import {HttpInterceptorFn, provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
|
3
|
+
import {
|
|
4
|
+
API_BASE_URL,
|
|
5
|
+
BRIDGE_DEFAULTS,
|
|
6
|
+
BRIDGE_LOGGER,
|
|
7
|
+
BRIDGE_WITH_CREDENTIALS,
|
|
8
|
+
MERCURE_HUB_URL,
|
|
9
|
+
MERCURE_TOPIC_MODE,
|
|
10
|
+
} from './tokens';
|
|
11
|
+
import {BridgeDefaults, BridgeLogger, MercureTopicMode} from './bridge.types';
|
|
4
12
|
import {contentTypeInterceptor} from './interceptors/content-type.interceptor';
|
|
13
|
+
import {bridgeDefaultsInterceptor} from './interceptors/bridge-defaults.interceptor';
|
|
14
|
+
import {bridgeDebugInterceptor} from './interceptors/bridge-debug.interceptor';
|
|
15
|
+
import {from, switchMap} from 'rxjs';
|
|
16
|
+
|
|
17
|
+
export type BridgeAuth =
|
|
18
|
+
| string
|
|
19
|
+
| {type: 'bearer'; token: string}
|
|
20
|
+
| {type: 'bearer'; getToken: () => string | undefined | Promise<string | undefined>}
|
|
21
|
+
| HttpInterceptorFn;
|
|
22
|
+
|
|
23
|
+
export interface BridgeMercureOptions {
|
|
24
|
+
hubUrl?: string;
|
|
25
|
+
init?: RequestInit;
|
|
26
|
+
topicMode?: MercureTopicMode;
|
|
27
|
+
}
|
|
5
28
|
|
|
6
29
|
export interface BridgeOptions {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
30
|
+
/** Base URL of the API (e.g. `http://localhost:8000`). */
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** Auth strategy used to attach an Authorization header. */
|
|
33
|
+
auth?: BridgeAuth;
|
|
34
|
+
/**
|
|
35
|
+
* Use `topicMode` to control the `topic=` values sent to the hub.
|
|
36
|
+
*/
|
|
37
|
+
mercure?: BridgeMercureOptions;
|
|
38
|
+
/** Default HTTP behaviour (headers, timeout, retries). */
|
|
39
|
+
defaults?: BridgeDefaults;
|
|
40
|
+
/** Enables debug logging via the debug interceptor and console logger. */
|
|
41
|
+
debug?: boolean;
|
|
42
|
+
/** Extra `HttpInterceptorFn` applied after bridge interceptors. */
|
|
43
|
+
extraInterceptors?: HttpInterceptorFn[];
|
|
11
44
|
}
|
|
12
45
|
|
|
46
|
+
/** Registers the bridge HTTP client, interceptors, Mercure realtime adapter and configuration tokens. */
|
|
13
47
|
export function provideBridge(opts: BridgeOptions): EnvironmentProviders {
|
|
14
48
|
const {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
49
|
+
baseUrl,
|
|
50
|
+
auth,
|
|
51
|
+
mercure,
|
|
52
|
+
defaults,
|
|
53
|
+
debug = false,
|
|
18
54
|
extraInterceptors = [],
|
|
19
|
-
|
|
20
55
|
} = opts;
|
|
21
56
|
|
|
57
|
+
if (!baseUrl) {
|
|
58
|
+
throw new Error("provideBridge(): missing 'baseUrl'");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const resolvedMercureInit: RequestInit = mercure?.init ?? {credentials: 'include' as RequestCredentials};
|
|
62
|
+
const resolvedMercureHubUrl = mercure?.hubUrl;
|
|
63
|
+
const resolvedMercureTopicMode: MercureTopicMode = mercure?.topicMode ?? 'url';
|
|
64
|
+
const withCredentials = resolveWithCredentials(resolvedMercureInit);
|
|
65
|
+
|
|
66
|
+
const loggerProvider: BridgeLogger = createBridgeLogger(debug);
|
|
67
|
+
|
|
68
|
+
const interceptors: HttpInterceptorFn[] = [
|
|
69
|
+
contentTypeInterceptor,
|
|
70
|
+
bridgeDefaultsInterceptor,
|
|
71
|
+
...createAuthInterceptors(auth),
|
|
72
|
+
...(debug ? [bridgeDebugInterceptor] : []),
|
|
73
|
+
...extraInterceptors,
|
|
74
|
+
];
|
|
75
|
+
|
|
22
76
|
return makeEnvironmentProviders([
|
|
23
77
|
provideHttpClient(
|
|
24
78
|
withFetch(),
|
|
25
|
-
withInterceptors(
|
|
26
|
-
contentTypeInterceptor,
|
|
27
|
-
...extraInterceptors
|
|
28
|
-
|
|
29
|
-
])
|
|
79
|
+
withInterceptors(interceptors)
|
|
30
80
|
),
|
|
31
|
-
{provide: API_BASE_URL, useValue:
|
|
32
|
-
{provide:
|
|
33
|
-
...(
|
|
81
|
+
{provide: API_BASE_URL, useValue: baseUrl},
|
|
82
|
+
{provide: BRIDGE_WITH_CREDENTIALS, useValue: withCredentials},
|
|
83
|
+
...(resolvedMercureHubUrl ? [{provide: MERCURE_HUB_URL, useValue: resolvedMercureHubUrl}] : []),
|
|
84
|
+
{provide: MERCURE_TOPIC_MODE, useValue: resolvedMercureTopicMode},
|
|
85
|
+
{provide: BRIDGE_DEFAULTS, useValue: defaults ?? {}},
|
|
86
|
+
{provide: BRIDGE_LOGGER, useValue: loggerProvider},
|
|
34
87
|
]);
|
|
35
88
|
}
|
|
89
|
+
|
|
90
|
+
function createBridgeLogger(debug: boolean): BridgeLogger {
|
|
91
|
+
const noop = () => undefined;
|
|
92
|
+
return {
|
|
93
|
+
debug: debug ? console.debug.bind(console) : noop,
|
|
94
|
+
info: debug ? console.info.bind(console) : noop,
|
|
95
|
+
warn: debug ? console.warn.bind(console) : noop,
|
|
96
|
+
error: console.error.bind(console),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveWithCredentials(init: RequestInit | undefined): boolean {
|
|
101
|
+
if (!init) return false;
|
|
102
|
+
const anyInit = init as RequestInit & {withCredentials?: boolean};
|
|
103
|
+
return anyInit.withCredentials === true || init.credentials === 'include';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createAuthInterceptors(auth?: BridgeAuth): HttpInterceptorFn[] {
|
|
107
|
+
if (!auth) return [];
|
|
108
|
+
|
|
109
|
+
if (typeof auth === 'function') {
|
|
110
|
+
return [auth as HttpInterceptorFn];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const bearer =
|
|
114
|
+
typeof auth === 'string'
|
|
115
|
+
? {type: 'bearer' as const, token: auth}
|
|
116
|
+
: (auth as Exclude<BridgeAuth, string | HttpInterceptorFn>);
|
|
117
|
+
|
|
118
|
+
if (bearer.type !== 'bearer') return [];
|
|
119
|
+
|
|
120
|
+
if ('token' in bearer) {
|
|
121
|
+
const token = bearer.token;
|
|
122
|
+
return [createBearerAuthInterceptor(() => token)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [createBearerAuthInterceptor(bearer.getToken)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createBearerAuthInterceptor(getToken: () => string | undefined | Promise<string | undefined>): HttpInterceptorFn {
|
|
129
|
+
return (req, next) => {
|
|
130
|
+
if (req.headers.has('Authorization')) return next(req);
|
|
131
|
+
return from(Promise.resolve(getToken())).pipe(
|
|
132
|
+
switchMap((token) => {
|
|
133
|
+
if (!token) return next(req);
|
|
134
|
+
return next(req.clone({headers: req.headers.set('Authorization', `Bearer ${token}`)}));
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import {InjectionToken} from '@angular/core';
|
|
2
|
+
import {BridgeDefaults, BridgeLogger, MercureTopicMode} from './bridge.types';
|
|
2
3
|
|
|
4
|
+
/** Base URL of the API (e.g. `http://localhost:8000`). */
|
|
3
5
|
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
|
|
4
6
|
|
|
7
|
+
/** Mercure hub URL (e.g. `http://localhost:8000/.well-known/mercure`). */
|
|
5
8
|
export const MERCURE_HUB_URL = new InjectionToken<string>('MERCURE_HUB_URL');
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Default credential policy for HTTP requests and Mercure EventSource.
|
|
12
|
+
* When `true`, the bridge sets `withCredentials: true` on HTTP calls and uses cookies for SSE.
|
|
13
|
+
*/
|
|
14
|
+
export const BRIDGE_WITH_CREDENTIALS = new InjectionToken<boolean>('BRIDGE_WITH_CREDENTIALS');
|
|
15
|
+
|
|
16
|
+
export const MERCURE_TOPIC_MODE = new InjectionToken<MercureTopicMode>('MERCURE_TOPIC_MODE');
|
|
17
|
+
|
|
18
|
+
export const BRIDGE_LOGGER = new InjectionToken<BridgeLogger>('BRIDGE_LOGGER');
|
|
19
|
+
|
|
20
|
+
export const BRIDGE_DEFAULTS = new InjectionToken<BridgeDefaults>('BRIDGE_DEFAULTS');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function joinUrl(base: string, path: string): string {
|
|
2
|
+
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
3
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
4
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves an API IRI (e.g. `/api/books/1`) to a full URL using the API base.
|
|
9
|
+
* If `path` is already absolute (http/https), it is returned as-is.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveUrl(base: string, path: string): string {
|
|
12
|
+
if (/^https?:\/\//i.test(path)) return path;
|
|
13
|
+
if (path.startsWith('//')) return path;
|
|
14
|
+
return joinUrl(base, path);
|
|
15
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export * from './lib/provide-bridge';
|
|
2
|
-
export * from './lib/
|
|
2
|
+
export * from './lib/bridge.types';
|
|
3
3
|
export * from './lib/facades/facade.factory';
|
|
4
|
+
export * from './lib/facades/bridge.facade';
|
|
4
5
|
export * from './lib/facades/resource.facade';
|
|
5
6
|
export * from './lib/ports/resource-repository.port';
|
|
6
|
-
export * from './lib/utils/
|
|
7
|
+
export * from './lib/utils/url';
|
|
7
8
|
|
|
8
9
|
export * from './models';
|