@obsidiane/meridiane 2.0.13 → 3.0.1
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 +33 -66
- package/cli.js +1 -10
- package/package.json +1 -1
- package/src/application/bridge-workflow.js +23 -66
- package/src/application/generate-bridge.js +32 -36
- package/src/application/run-build.js +8 -30
- package/src/application/run-dev.js +8 -63
- package/src/application/run-generate.js +8 -47
- package/src/domain/bridge-options.js +10 -63
- package/src/generators/bridge/write-bridge-files.js +11 -29
- package/src/generators/models/interface-schema.js +3 -20
- package/src/generators/models/model-selection.js +67 -106
- package/src/generators/models/oas-contract.js +1 -108
- package/src/generators/models/oas-schema-name.js +1 -19
- package/src/generators/models/openapi-to-models.js +51 -42
- package/src/generators/models/schema-utils.js +0 -34
- package/src/generators/models/type-resolver.js +2 -20
- package/src/generators/models/utils.js +0 -11
- package/src/generators/models/write-model-files.js +5 -15
- package/src/infrastructure/dev-project.js +0 -33
- package/src/infrastructure/files.js +15 -0
- package/src/infrastructure/local-package-install.js +2 -2
- package/src/infrastructure/logger.js +0 -2
- package/templates/angular-bridge/README.md +29 -281
- package/templates/angular-bridge/package.json +2 -2
- package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +10 -19
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-hub.ts +449 -0
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.ts +28 -0
- package/templates/angular-bridge/src/lib/bridge/sse/resource-topic.ts +83 -0
- package/templates/angular-bridge/src/lib/bridge-options.ts +21 -17
- package/templates/angular-bridge/src/lib/bridge.types.ts +22 -33
- package/templates/angular-bridge/src/lib/facades/bridge.facade.ts +58 -134
- package/templates/angular-bridge/src/lib/facades/facade.factory.ts +25 -25
- package/templates/angular-bridge/src/lib/facades/facade.interface.ts +4 -9
- package/templates/angular-bridge/src/lib/facades/resource.facade.ts +38 -61
- package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +8 -12
- package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +19 -11
- package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +5 -17
- package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +28 -24
- package/templates/angular-bridge/src/lib/ports/realtime.port.ts +34 -53
- package/templates/angular-bridge/src/lib/ports/resource-repository.port.ts +31 -21
- package/templates/angular-bridge/src/lib/provide-bridge.ts +14 -38
- package/templates/angular-bridge/src/lib/tokens.ts +4 -15
- package/templates/angular-bridge/src/lib/utils/request-url.ts +4 -6
- package/templates/angular-bridge/src/lib/utils/url.ts +32 -9
- package/templates/angular-bridge/src/public-api.ts +0 -1
- package/templates/angular-bridge/tsconfig.lib.json +0 -3
- package/src/README.md +0 -9
- package/src/infrastructure/json.js +0 -71
- package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +0 -82
- package/templates/angular-bridge/src/lib/bridge/sse/eventsource-wrapper.ts +0 -70
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +0 -255
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +0 -82
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -48
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +0 -39
- package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +0 -358
- package/templates/angular-bridge/src/lib/bridge/sse/ref-count-topic.registry.ts +0 -36
- package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +0 -76
- package/templates/angular-bridge/src/lib/utils/iri.ts +0 -6
|
@@ -25,46 +25,35 @@ export interface BridgeDefaults {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
* - `
|
|
30
|
-
* - `
|
|
28
|
+
* State of the Mercure connections.
|
|
29
|
+
* - `closed`: no active subscription (or realtime disabled)
|
|
30
|
+
* - `connecting`: at least one connection is opening, reconnecting, or waiting before a retry
|
|
31
|
+
* - `connected`: the hub accepted every connection and the streams are open
|
|
31
32
|
*/
|
|
32
|
-
export type
|
|
33
|
+
export type RealtimeStatus = 'closed' | 'connecting' | 'connected';
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
export type MercureConnectionMode = 'single' | 'auto';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Per-subscription realtime override.
|
|
43
|
-
* - `true`: force an isolated SSE connection for this watch call
|
|
44
|
-
* - `false`/`undefined`: use adapter strategy (`single` or `auto`)
|
|
45
|
-
*/
|
|
46
|
-
export interface WatchConnectionOptions {
|
|
47
|
-
newConnection?: boolean;
|
|
35
|
+
export interface RealtimeSubscriptionDiagnostics {
|
|
36
|
+
match: string;
|
|
37
|
+
type: 'exact' | 'urlpattern';
|
|
38
|
+
/** Number of active `watch$()` streams sharing this subscription. */
|
|
39
|
+
consumers: number;
|
|
48
40
|
}
|
|
49
41
|
|
|
50
|
-
export type RealtimeConnectionScope = 'shared' | 'dedicated';
|
|
51
|
-
|
|
52
|
-
export type RealtimeConnectionStatus = 'connecting' | 'connected' | 'closed';
|
|
53
|
-
|
|
54
42
|
export interface RealtimeConnectionDiagnostics {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
status:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
43
|
+
/** `shared`, `dedicated`, or the name given with `watch$(..., {connection})`. */
|
|
44
|
+
connection: string;
|
|
45
|
+
status: RealtimeStatus;
|
|
46
|
+
/** Subscribe URL (without `last_event_id`). */
|
|
47
|
+
url?: string;
|
|
48
|
+
subscriptions: RealtimeSubscriptionDiagnostics[];
|
|
49
|
+
/** Last update ID received on this connection, used to request replay after a reconnection. */
|
|
50
|
+
lastEventId?: string;
|
|
51
|
+
/** Consecutive connections refused by the hub (authentication, authorization, limits). */
|
|
52
|
+
failures: number;
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
export interface RealtimeDiagnostics {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
totalConnections: number;
|
|
67
|
-
sharedConnections: number;
|
|
68
|
-
dedicatedConnections: number;
|
|
56
|
+
status: RealtimeStatus;
|
|
57
|
+
/** One entry per `EventSource`: subscriptions are split when a subscribe URL would exceed the limits. */
|
|
69
58
|
connections: RealtimeConnectionDiagnostics[];
|
|
70
59
|
}
|
|
@@ -1,165 +1,100 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {inject, Injectable} from '@angular/core';
|
|
2
|
+
import {DOCUMENT} from '@angular/common';
|
|
2
3
|
import {HttpClient} from '@angular/common/http';
|
|
3
|
-
import {Observable} from 'rxjs';
|
|
4
|
-
import {filter, map, share} from 'rxjs/operators';
|
|
4
|
+
import {map, Observable} from 'rxjs';
|
|
5
5
|
import {toHttpParams} from '../bridge/rest/query-builder';
|
|
6
6
|
import {buildHttpRequestOptions, resolveHttpCallOptions} from '../bridge/rest/http-request.options';
|
|
7
|
-
import {
|
|
7
|
+
import {MercureHub} from '../bridge/sse/mercure-hub';
|
|
8
8
|
import {API_BASE_URL, BRIDGE_HTTP_WITH_CREDENTIALS} from '../tokens';
|
|
9
|
-
import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri,
|
|
10
|
-
import {
|
|
11
|
-
import {resolveUrl} from '../utils/url';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export type WatchTypesResult<R extends Record<string, any>> =
|
|
21
|
-
{ [K in keyof R]: TypedEvent<K & string, R[K]> }[keyof R];
|
|
22
|
-
|
|
23
|
-
export type WatchTypesConfig = {
|
|
24
|
-
/** Field name used as discriminator. Default: `@type` (JSON-LD). */
|
|
25
|
-
discriminator?: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export type WatchSubscriptionOptions = WatchConnectionOptions;
|
|
9
|
+
import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, Item} from '../ports/resource-repository.port';
|
|
10
|
+
import {MercureSubscription, RealtimePort, RealtimeSubscribeOptions} from '../ports/realtime.port';
|
|
11
|
+
import {resolveUrl, toAbsoluteUrl} from '../utils/url';
|
|
12
|
+
import {RealtimeDiagnostics, RealtimeStatus} from '../bridge.types';
|
|
13
|
+
|
|
14
|
+
/** Mercure selector of `BridgeFacade.watch$()`: an IRI / URL, or a URL Pattern. Relative values use `baseUrl`. */
|
|
15
|
+
export interface RealtimeSelector {
|
|
16
|
+
match: string;
|
|
17
|
+
/** Default: `exact`. */
|
|
18
|
+
type?: MercureSubscription['type'];
|
|
19
|
+
}
|
|
29
20
|
|
|
30
21
|
/**
|
|
31
|
-
*
|
|
22
|
+
* HTTP client of the bridge (every call resolves its IRI against `baseUrl`), and access to the Mercure connections.
|
|
32
23
|
*
|
|
33
24
|
* Prefer `FacadeFactory` + `ResourceFacade<T>` when you want a resource-oriented API.
|
|
34
25
|
*/
|
|
35
26
|
@Injectable({providedIn: 'root'})
|
|
36
27
|
export class BridgeFacade {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
28
|
+
private readonly http = inject(HttpClient);
|
|
29
|
+
private readonly realtime: RealtimePort = inject(MercureHub);
|
|
30
|
+
private readonly apiBase = inject(API_BASE_URL);
|
|
31
|
+
private readonly httpWithCredentialsDefault = inject(BRIDGE_HTTP_WITH_CREDENTIALS);
|
|
32
|
+
/** Mercure topics are absolute URLs: a relative `baseUrl` is resolved against the document base. */
|
|
33
|
+
private readonly topicBaseUrl = toAbsoluteUrl(this.apiBase, inject(DOCUMENT).baseURI);
|
|
34
|
+
|
|
35
|
+
get$<R = unknown>(url: Iri, options?: HttpCallOptions): Observable<R> {
|
|
36
|
+
return this.http.get<R>(this.resolveUrl(url), resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
|
|
43
37
|
}
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
return this.http.get<R>(
|
|
47
|
-
this.resolveUrl(url),
|
|
48
|
-
resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
getCollection$<T extends Item = Item>(
|
|
53
|
-
url: IriRequired,
|
|
54
|
-
query?: AnyQuery,
|
|
55
|
-
options?: HttpCallOptions
|
|
56
|
-
): Observable<Collection<T>> {
|
|
39
|
+
getCollection$<T extends Item = Item>(url: Iri, query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>> {
|
|
57
40
|
return this.http.get<Collection<T>>(this.resolveUrl(url), {
|
|
58
41
|
...resolveHttpCallOptions(options, this.httpWithCredentialsDefault),
|
|
59
42
|
params: toHttpParams(query),
|
|
60
43
|
});
|
|
61
44
|
}
|
|
62
45
|
|
|
63
|
-
post$<R = unknown, B = unknown>(url:
|
|
64
|
-
return this.http.post<R>(
|
|
65
|
-
this.resolveUrl(url),
|
|
66
|
-
payload,
|
|
67
|
-
resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
|
|
68
|
-
);
|
|
46
|
+
post$<R = unknown, B = unknown>(url: Iri, payload: B, options?: HttpCallOptions): Observable<R> {
|
|
47
|
+
return this.http.post<R>(this.resolveUrl(url), payload, resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
|
|
69
48
|
}
|
|
70
49
|
|
|
71
|
-
patch$<R = unknown, B = unknown>(url:
|
|
72
|
-
return this.http.patch<R>(
|
|
73
|
-
this.resolveUrl(url),
|
|
74
|
-
changes,
|
|
75
|
-
resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
|
|
76
|
-
);
|
|
50
|
+
patch$<R = unknown, B = unknown>(url: Iri, changes: B, options?: HttpCallOptions): Observable<R> {
|
|
51
|
+
return this.http.patch<R>(this.resolveUrl(url), changes, resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
|
|
77
52
|
}
|
|
78
53
|
|
|
79
|
-
put$<R = unknown, B = unknown>(url:
|
|
80
|
-
return this.http.put<R>(
|
|
81
|
-
this.resolveUrl(url),
|
|
82
|
-
payload,
|
|
83
|
-
resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
|
|
84
|
-
);
|
|
54
|
+
put$<R = unknown, B = unknown>(url: Iri, payload: B, options?: HttpCallOptions): Observable<R> {
|
|
55
|
+
return this.http.put<R>(this.resolveUrl(url), payload, resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
|
|
85
56
|
}
|
|
86
57
|
|
|
87
|
-
delete$(url:
|
|
88
|
-
return this.http.delete<void>(
|
|
89
|
-
this.resolveUrl(url),
|
|
90
|
-
resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
|
|
91
|
-
);
|
|
58
|
+
delete$(url: Iri, options?: HttpCallOptions): Observable<void> {
|
|
59
|
+
return this.http.delete<void>(this.resolveUrl(url), resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
|
|
92
60
|
}
|
|
93
61
|
|
|
62
|
+
/** Low-level escape hatch for non-standard endpoints (custom controllers, uploads, other formats...). */
|
|
94
63
|
request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
watch$<T = Item>(
|
|
103
|
-
iri: Iri | Iri[],
|
|
104
|
-
subscribeFilter?: SubscribeFilter,
|
|
105
|
-
options?: WatchSubscriptionOptions
|
|
106
|
-
): Observable<T> {
|
|
107
|
-
const iris = toIriList(iri);
|
|
108
|
-
return this.realtime
|
|
109
|
-
.subscribe$<T>(iris, subscribeFilter, options)
|
|
110
|
-
.pipe(
|
|
111
|
-
map((event) => event.data),
|
|
112
|
-
filter((data): data is T => data !== undefined),
|
|
113
|
-
share()
|
|
114
|
-
);
|
|
64
|
+
return this.http.request<R>(
|
|
65
|
+
request.method,
|
|
66
|
+
this.resolveUrl(request.url),
|
|
67
|
+
buildHttpRequestOptions(request, this.httpWithCredentialsDefault)
|
|
68
|
+
);
|
|
115
69
|
}
|
|
116
70
|
|
|
117
71
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* Important:
|
|
121
|
-
* - filtering is done on payload content (e.g. `@type`), not on network topic provenance.
|
|
122
|
-
* - in shared mode (`connectionMode: 'auto'` without `newConnection`), events from another shared topic
|
|
123
|
-
* can be emitted if they carry an allowed discriminator.
|
|
72
|
+
* Streams the JSON payloads of a custom Mercure subscription, without filtering them.
|
|
124
73
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* (or `connectionMode: 'single'` globally).
|
|
74
|
+
* The default connection is `dedicated`: every update received on it belongs to this subscription. On a
|
|
75
|
+
* shared or named connection, the stream also carries the updates of the other subscriptions of that connection.
|
|
128
76
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
): Observable<WatchTypesResult<R>> {
|
|
135
|
-
const iris = toIriList(iri);
|
|
136
|
-
const discriminator = config?.discriminator ?? '@type';
|
|
137
|
-
const allowedTypes = new Set(
|
|
138
|
-
resourceTypes.filter((type): type is string => typeof type === 'string' && type.length > 0)
|
|
139
|
-
);
|
|
140
|
-
|
|
77
|
+
watch$<T = unknown>(selector: RealtimeSelector, options?: RealtimeSubscribeOptions): Observable<T> {
|
|
78
|
+
const subscription: MercureSubscription = {
|
|
79
|
+
match: resolveUrl(this.topicBaseUrl, selector.match),
|
|
80
|
+
type: selector.type ?? 'exact',
|
|
81
|
+
};
|
|
141
82
|
return this.realtime
|
|
142
|
-
.
|
|
143
|
-
.pipe(
|
|
144
|
-
|
|
145
|
-
const raw = event.data;
|
|
146
|
-
|
|
147
|
-
const resolvedType = readDiscriminator(raw, discriminator);
|
|
148
|
-
if (typeof resolvedType !== 'string') return undefined;
|
|
149
|
-
if (!allowedTypes.has(resolvedType)) return undefined;
|
|
83
|
+
.subscribe$(subscription, {connection: options?.connection ?? 'dedicated'})
|
|
84
|
+
.pipe(map((update) => update.data as T));
|
|
85
|
+
}
|
|
150
86
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
filter((event): event is WatchTypesResult<R> => !!event),
|
|
155
|
-
share()
|
|
156
|
-
);
|
|
87
|
+
/** Renews the Mercure authorization and reopens every connection, e.g. after login or logout. */
|
|
88
|
+
reconnectRealtime(): void {
|
|
89
|
+
this.realtime.reconnect();
|
|
157
90
|
}
|
|
158
91
|
|
|
159
|
-
|
|
160
|
-
|
|
92
|
+
/** State of the Mercure connections. */
|
|
93
|
+
realtimeStatus$(): Observable<RealtimeStatus> {
|
|
94
|
+
return this.realtime.status$();
|
|
161
95
|
}
|
|
162
96
|
|
|
97
|
+
/** Mercure connections, their subscriptions and resume positions. */
|
|
163
98
|
realtimeDiagnostics$(): Observable<RealtimeDiagnostics> {
|
|
164
99
|
return this.realtime.diagnostics$();
|
|
165
100
|
}
|
|
@@ -169,14 +104,3 @@ export class BridgeFacade {
|
|
|
169
104
|
return resolveUrl(this.apiBase, path);
|
|
170
105
|
}
|
|
171
106
|
}
|
|
172
|
-
|
|
173
|
-
function readDiscriminator(payload: unknown, discriminator: string): string | undefined {
|
|
174
|
-
if (!payload || typeof payload !== 'object') return undefined;
|
|
175
|
-
const value = (payload as Record<string, unknown>)[discriminator];
|
|
176
|
-
if (typeof value === 'string') return value;
|
|
177
|
-
if (Array.isArray(value)) {
|
|
178
|
-
const first = value.find((v) => typeof v === 'string');
|
|
179
|
-
return typeof first === 'string' ? first : undefined;
|
|
180
|
-
}
|
|
181
|
-
return undefined;
|
|
182
|
-
}
|
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import {inject, Injectable} from '@angular/core';
|
|
2
|
+
import {DOCUMENT} from '@angular/common';
|
|
3
3
|
import {RealtimePort} from '../ports/realtime.port';
|
|
4
|
-
import {Item
|
|
5
|
-
import {API_BASE_URL
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import {Item} from '../ports/resource-repository.port';
|
|
5
|
+
import {API_BASE_URL} from '../tokens';
|
|
6
|
+
import {MercureHub} from '../bridge/sse/mercure-hub';
|
|
7
|
+
import {ResourceTopic} from '../bridge/sse/resource-topic';
|
|
8
|
+
import {resolveUrl, toAbsoluteUrl, trimTrailingSlash} from '../utils/url';
|
|
9
|
+
import {BridgeFacade} from './bridge.facade';
|
|
8
10
|
import {ResourceFacade} from './resource.facade';
|
|
9
11
|
|
|
10
|
-
export type FacadeConfig
|
|
12
|
+
export type FacadeConfig = {
|
|
13
|
+
/** Collection path of the resource (e.g. `/api/books`). */
|
|
11
14
|
url: string;
|
|
12
|
-
|
|
15
|
+
/**
|
|
16
|
+
* URL Pattern of the resource item IRIs, i.e. the Mercure topics API Platform publishes on.
|
|
17
|
+
* Default: `{url}/:id`. Relative patterns are resolved against `baseUrl`.
|
|
18
|
+
*/
|
|
19
|
+
topic?: string;
|
|
13
20
|
realtime?: RealtimePort;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
@Injectable({providedIn: 'root'})
|
|
17
24
|
export class FacadeFactory {
|
|
18
|
-
private readonly
|
|
19
|
-
private readonly
|
|
20
|
-
|
|
21
|
-
private readonly
|
|
22
|
-
private readonly mercure = inject(MercureRealtimeAdapter);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Creates a `ResourceFacade<T>`.
|
|
26
|
-
*
|
|
27
|
-
* Important: `ResourceFacade` uses `toSignal()`, which requires an injection context.
|
|
28
|
-
* This factory ensures that by using `runInInjectionContext`.
|
|
29
|
-
*/
|
|
30
|
-
create<T extends Item>(config: FacadeConfig<T>): ResourceFacade<T> {
|
|
31
|
-
const url = config.url;
|
|
32
|
-
const repo = config.repo ?? new ApiPlatformRestRepository<T>(this.http, this.baseUrl, url, this.httpWithCredentials);
|
|
33
|
-
const realtime = config.realtime ?? this.mercure;
|
|
25
|
+
private readonly bridge = inject(BridgeFacade);
|
|
26
|
+
private readonly mercure = inject(MercureHub);
|
|
27
|
+
/** Mercure topics are absolute URLs: a relative `baseUrl` is resolved against the document base. */
|
|
28
|
+
private readonly topicBaseUrl = toAbsoluteUrl(inject(API_BASE_URL), inject(DOCUMENT).baseURI);
|
|
34
29
|
|
|
35
|
-
|
|
30
|
+
create<T extends Item>(config: FacadeConfig): ResourceFacade<T> {
|
|
31
|
+
const topic = new ResourceTopic(
|
|
32
|
+
resolveUrl(this.topicBaseUrl, config.topic ?? `${trimTrailingSlash(config.url)}/:id`),
|
|
33
|
+
this.topicBaseUrl
|
|
34
|
+
);
|
|
35
|
+
return new ResourceFacade<T>(this.bridge, config.url, config.realtime ?? this.mercure, topic);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Iri,
|
|
3
|
-
Item,
|
|
4
|
-
ResourceRepository,
|
|
5
|
-
} from '../ports/resource-repository.port';
|
|
1
|
+
import {Iri, Item, ResourceRepository} from '../ports/resource-repository.port';
|
|
6
2
|
import {Observable} from 'rxjs';
|
|
7
|
-
import {
|
|
3
|
+
import {ResourceDeletion} from '../bridge/sse/resource-topic';
|
|
4
|
+
import {RealtimeSubscribeOptions} from '../ports/realtime.port';
|
|
8
5
|
|
|
9
6
|
export interface Facade<T extends Item> extends ResourceRepository<T> {
|
|
10
|
-
watch$(iri
|
|
11
|
-
|
|
12
|
-
unwatch(iri: Iri | Iri[]): void;
|
|
7
|
+
watch$(iri?: Iri, options?: RealtimeSubscribeOptions): Observable<T | ResourceDeletion>;
|
|
13
8
|
}
|
|
@@ -1,95 +1,72 @@
|
|
|
1
1
|
import {Signal} from '@angular/core';
|
|
2
|
-
import {
|
|
3
|
-
ResourceRepository,
|
|
4
|
-
AnyQuery,
|
|
5
|
-
HttpCallOptions,
|
|
6
|
-
Iri,
|
|
7
|
-
IriRequired,
|
|
8
|
-
Collection,
|
|
9
|
-
Item,
|
|
10
|
-
HttpRequestConfig,
|
|
11
|
-
} from '../ports/resource-repository.port';
|
|
12
|
-
import {RealtimePort, RealtimeStatus} from '../ports/realtime.port';
|
|
13
|
-
import {toSignal} from '@angular/core/rxjs-interop';
|
|
14
2
|
import {filter, map, Observable, share} from 'rxjs';
|
|
3
|
+
import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, Item} from '../ports/resource-repository.port';
|
|
4
|
+
import {RealtimePort, RealtimeSubscribeOptions} from '../ports/realtime.port';
|
|
5
|
+
import {RealtimeStatus} from '../bridge.types';
|
|
6
|
+
import {ResourceDeletion, ResourceTopic} from '../bridge/sse/resource-topic';
|
|
7
|
+
import {BridgeFacade} from './bridge.facade';
|
|
15
8
|
import {Facade} from './facade.interface';
|
|
16
|
-
import {WatchConnectionOptions} from '../bridge.types';
|
|
17
|
-
import {toIriList} from '../utils/iri';
|
|
18
9
|
|
|
10
|
+
export {isResourceDeletion} from '../bridge/sse/resource-topic';
|
|
11
|
+
export type {ResourceDeletion} from '../bridge/sse/resource-topic';
|
|
12
|
+
|
|
13
|
+
/** API of one API Platform resource: its collection path for HTTP, its URL Pattern for Mercure. */
|
|
19
14
|
export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
20
15
|
readonly connectionStatus: Signal<RealtimeStatus>;
|
|
21
16
|
|
|
22
17
|
constructor(
|
|
23
|
-
protected readonly
|
|
24
|
-
protected readonly
|
|
18
|
+
protected readonly bridge: BridgeFacade,
|
|
19
|
+
protected readonly url: Iri,
|
|
20
|
+
protected readonly realtime: RealtimePort,
|
|
21
|
+
protected readonly topic: ResourceTopic,
|
|
25
22
|
) {
|
|
26
|
-
this.connectionStatus =
|
|
23
|
+
this.connectionStatus = realtime.status;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
getCollection$(query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>> {
|
|
30
|
-
return this.
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
get$(iri: IriRequired, options?: HttpCallOptions): Observable<T> {
|
|
34
|
-
return this.repo.get$(iri, options);
|
|
27
|
+
return this.bridge.getCollection$<T>(this.url, query, options);
|
|
35
28
|
}
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
return this.
|
|
30
|
+
get$(iri: Iri, options?: HttpCallOptions): Observable<T> {
|
|
31
|
+
return this.bridge.get$<T>(iri, options);
|
|
39
32
|
}
|
|
40
33
|
|
|
41
34
|
post$(payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
|
|
42
|
-
return this.
|
|
35
|
+
return this.bridge.post$<T, Partial<T>>(this.url, payload, options);
|
|
43
36
|
}
|
|
44
37
|
|
|
45
|
-
|
|
46
|
-
return this.
|
|
38
|
+
patch$(iri: Iri, changes: Partial<T>, options?: HttpCallOptions): Observable<T> {
|
|
39
|
+
return this.bridge.patch$<T, Partial<T>>(iri, changes, options);
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
return this.
|
|
42
|
+
put$(iri: Iri, payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
|
|
43
|
+
return this.bridge.put$<T, Partial<T>>(iri, payload, options);
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
return this.
|
|
46
|
+
delete$(iri: Iri, options?: HttpCallOptions): Observable<void> {
|
|
47
|
+
return this.bridge.delete$(iri, options);
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T> {
|
|
62
|
-
return this.subscribeAndSync(toIriList(iri), options);
|
|
63
|
-
}
|
|
64
|
-
unwatch(iri: Iri | Iri[]): void {
|
|
65
|
-
this.realtime.unsubscribe(toIriList(iri));
|
|
50
|
+
/** Low-level escape hatch; `url` defaults to the collection path of the resource. */
|
|
51
|
+
request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
|
|
52
|
+
return this.bridge.request$<R, B>({...request, url: request.url ?? this.url});
|
|
66
53
|
}
|
|
67
54
|
|
|
68
55
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
56
|
+
* Streams changes of one item when an IRI is supplied, or of the resource's items otherwise.
|
|
57
|
+
* Payloads are forwarded unchanged, including API Platform deletion payloads.
|
|
58
|
+
*
|
|
59
|
+
* Item subscriptions use `match`; resource subscriptions use `match_urlpattern`. Updates are kept by `@id`,
|
|
60
|
+
* so `connection` (default `shared`) only chooses the SSE connection carrying the subscription.
|
|
61
|
+
* Unsubscribing releases this consumer; other consumers keep sharing the subscription.
|
|
71
62
|
*/
|
|
72
|
-
|
|
73
|
-
iri
|
|
74
|
-
field: string,
|
|
75
|
-
options?: WatchConnectionOptions
|
|
76
|
-
): Observable<R> {
|
|
77
|
-
const iris = toIriList(iri);
|
|
78
|
-
return this.realtime
|
|
79
|
-
.subscribe$<R>(iris, {field}, options)
|
|
80
|
-
.pipe(
|
|
81
|
-
map((event) => event.data),
|
|
82
|
-
filter((data): data is R => data !== undefined),
|
|
83
|
-
share()
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
protected subscribeAndSync(iris: string[], options?: WatchConnectionOptions): Observable<T> {
|
|
63
|
+
watch$(iri?: Iri, options?: RealtimeSubscribeOptions): Observable<T | ResourceDeletion> {
|
|
64
|
+
const topic = iri === undefined ? this.topic : this.topic.forIri(iri);
|
|
88
65
|
return this.realtime
|
|
89
|
-
.subscribe
|
|
66
|
+
.subscribe$({match: topic.match, type: topic.type}, options)
|
|
90
67
|
.pipe(
|
|
91
|
-
map((
|
|
92
|
-
filter((
|
|
68
|
+
map((update) => topic.toResource<T>(update.data)),
|
|
69
|
+
filter((resource): resource is T | ResourceDeletion => resource !== undefined),
|
|
93
70
|
share()
|
|
94
71
|
);
|
|
95
72
|
}
|
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
import {HttpInterceptorFn} from '@angular/common/http';
|
|
2
2
|
import {from, switchMap} from 'rxjs';
|
|
3
3
|
import {BridgeAuth} from '../bridge-options';
|
|
4
|
-
import {isRequestWithinBaseUrl} from '../utils/request-url';
|
|
5
4
|
|
|
6
5
|
type TokenProvider = () => string | undefined | Promise<string | undefined>;
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
const bearer = typeof auth === 'string' ? {type: 'bearer' as const, token: auth} : auth;
|
|
13
|
-
if (bearer.type !== 'bearer') return [];
|
|
7
|
+
/** Builds the auth interceptor; `provideBridge` limits it to requests targeting the API. */
|
|
8
|
+
export function createAuthInterceptor(auth: BridgeAuth | undefined): HttpInterceptorFn | undefined {
|
|
9
|
+
if (!auth) return undefined;
|
|
10
|
+
if (typeof auth === 'function') return auth;
|
|
14
11
|
|
|
12
|
+
const bearer = typeof auth === 'string' ? {token: auth} : auth;
|
|
15
13
|
const getToken: TokenProvider = 'token' in bearer ? () => bearer.token : bearer.getToken;
|
|
16
|
-
return
|
|
14
|
+
return createBearerAuthInterceptor(getToken);
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
function createBearerAuthInterceptor(
|
|
17
|
+
function createBearerAuthInterceptor(getToken: TokenProvider): HttpInterceptorFn {
|
|
20
18
|
return (request, next) => {
|
|
21
|
-
if (
|
|
22
|
-
return next(request);
|
|
23
|
-
}
|
|
19
|
+
if (request.headers.has('Authorization')) return next(request);
|
|
24
20
|
|
|
25
21
|
return from(Promise.resolve(getToken())).pipe(
|
|
26
22
|
switchMap((token) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {HttpInterceptorFn, HttpRequest} from '@angular/common/http';
|
|
1
|
+
import {HttpErrorResponse, HttpInterceptorFn, HttpRequest} from '@angular/common/http';
|
|
2
2
|
import {inject} from '@angular/core';
|
|
3
|
-
import {retry,
|
|
3
|
+
import {retry, throwError, timer} from 'rxjs';
|
|
4
4
|
import {BridgeDefaults} from '../bridge.types';
|
|
5
5
|
import {BRIDGE_DEFAULTS, BRIDGE_LOGGER} from '../tokens';
|
|
6
6
|
|
|
@@ -18,22 +18,18 @@ type RetryPolicy = {
|
|
|
18
18
|
export const bridgeDefaultsInterceptor: HttpInterceptorFn = (req, next) => {
|
|
19
19
|
const defaults = inject(BRIDGE_DEFAULTS, {optional: true}) ?? {};
|
|
20
20
|
const logger = inject(BRIDGE_LOGGER, {optional: true});
|
|
21
|
-
const request = applyDefaultHeaders(req, defaults.headers);
|
|
22
|
-
|
|
23
|
-
let response$ = next(request);
|
|
24
|
-
if (typeof defaults.timeoutMs === 'number' && defaults.timeoutMs > 0) {
|
|
25
|
-
response$ = response$.pipe(timeout({first: defaults.timeoutMs}));
|
|
26
|
-
}
|
|
21
|
+
const request = applyTimeout(applyDefaultHeaders(req, defaults.headers), defaults.timeoutMs);
|
|
27
22
|
|
|
28
23
|
const retryPolicy = resolveRetryPolicy(defaults.retries);
|
|
29
24
|
if (!retryPolicy || !retryPolicy.methods.has(req.method.toUpperCase())) {
|
|
30
|
-
return
|
|
25
|
+
return next(request);
|
|
31
26
|
}
|
|
32
27
|
|
|
33
|
-
return
|
|
28
|
+
return next(request).pipe(
|
|
34
29
|
retry({
|
|
35
30
|
count: retryPolicy.count,
|
|
36
|
-
delay: (
|
|
31
|
+
delay: (error, retryIndex) => {
|
|
32
|
+
if (!isRetryable(error)) return throwError(() => error);
|
|
37
33
|
logger?.debug?.('[Bridge] retry', {url: req.urlWithParams, method: req.method, retryIndex});
|
|
38
34
|
return timer(retryPolicy.delayMs);
|
|
39
35
|
},
|
|
@@ -52,6 +48,18 @@ function applyDefaultHeaders(request: HttpRequest<unknown>, defaults?: Record<st
|
|
|
52
48
|
return headers === request.headers ? request : request.clone({headers});
|
|
53
49
|
}
|
|
54
50
|
|
|
51
|
+
/** Uses the native request timeout, which aborts the request; an explicit per-request timeout wins. */
|
|
52
|
+
function applyTimeout(request: HttpRequest<unknown>, timeoutMs?: number): HttpRequest<unknown> {
|
|
53
|
+
if (typeof timeoutMs !== 'number' || timeoutMs <= 0 || request.timeout !== undefined) return request;
|
|
54
|
+
return request.clone({timeout: timeoutMs});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Network errors (status 0), rate limiting and server errors are transient; other responses are final. */
|
|
58
|
+
function isRetryable(error: unknown): boolean {
|
|
59
|
+
if (!(error instanceof HttpErrorResponse)) return false;
|
|
60
|
+
return error.status === 0 || error.status === 429 || error.status >= 500;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
function resolveRetryPolicy(retries: BridgeDefaults['retries']): RetryPolicy | undefined {
|
|
56
64
|
const count = typeof retries === 'number' ? retries : retries?.count;
|
|
57
65
|
if (!count || count <= 0) return undefined;
|