@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,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {BehaviorSubject, defer, fromEvent, Observable, of, Subject} from 'rxjs';
|
|
1
|
+
import {Inject, Injectable, OnDestroy, Optional, PLATFORM_ID} from '@angular/core';
|
|
2
|
+
import {BehaviorSubject, defer, EMPTY, fromEvent, Observable, of, Subject} from 'rxjs';
|
|
3
3
|
import {auditTime, concatMap, filter, finalize, map, share, takeUntil} from 'rxjs/operators';
|
|
4
|
-
import {API_BASE_URL,
|
|
4
|
+
import {API_BASE_URL, BRIDGE_LOGGER, BRIDGE_WITH_CREDENTIALS, MERCURE_HUB_URL, MERCURE_TOPIC_MODE} from '../../tokens';
|
|
5
|
+
import {BridgeLogger, MercureTopicMode} from '../../bridge.types';
|
|
5
6
|
import {RealtimeEvent, RealtimePort, RealtimeStatus} from '../../ports/realtime.port';
|
|
6
7
|
import {EventSourceWrapper} from './eventsource-wrapper';
|
|
7
|
-
import {Item} from
|
|
8
|
+
import {Item} from '../../ports/resource-repository.port';
|
|
8
9
|
import {isPlatformBrowser} from '@angular/common';
|
|
9
10
|
import {MercureUrlBuilder} from './mercure-url.builder';
|
|
10
11
|
import {RefCountTopicRegistry} from './ref-count-topic.registry';
|
|
11
|
-
import {
|
|
12
|
+
import {MercureTopicMapper} from './mercure-topic.mapper';
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@Injectable({providedIn: 'root'})
|
|
@@ -19,7 +20,7 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
19
20
|
|
|
20
21
|
private readonly topicsRegistry = new RefCountTopicRegistry();
|
|
21
22
|
private readonly urlBuilder: MercureUrlBuilder;
|
|
22
|
-
private readonly
|
|
23
|
+
private readonly topicMapper: MercureTopicMapper;
|
|
23
24
|
|
|
24
25
|
private readonly destroy$ = new Subject<void>();
|
|
25
26
|
private connectionStop$ = new Subject<void>();
|
|
@@ -31,13 +32,16 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
31
32
|
|
|
32
33
|
constructor(
|
|
33
34
|
@Inject(API_BASE_URL) private readonly apiBase: string,
|
|
34
|
-
@Inject(
|
|
35
|
-
@Inject(MERCURE_HUB_URL) private readonly hubUrl: string,
|
|
36
|
-
@Inject(DOCUMENT) private readonly doc: Document,
|
|
35
|
+
@Inject(BRIDGE_WITH_CREDENTIALS) private readonly withCredentialsDefault: boolean,
|
|
37
36
|
@Inject(PLATFORM_ID) private readonly platformId: object,
|
|
37
|
+
@Optional() @Inject(MERCURE_HUB_URL) private readonly hubUrl?: string,
|
|
38
|
+
@Optional() @Inject(MERCURE_TOPIC_MODE) topicMode?: MercureTopicMode,
|
|
39
|
+
@Optional() @Inject(BRIDGE_LOGGER) private readonly logger?: BridgeLogger,
|
|
38
40
|
) {
|
|
39
|
-
this.urlBuilder = new MercureUrlBuilder(
|
|
40
|
-
|
|
41
|
+
this.urlBuilder = new MercureUrlBuilder();
|
|
42
|
+
// `topicMode` affects only the `topic=` query param sent to the hub.
|
|
43
|
+
// Payload IRIs are always matched using same-origin relative IRIs (`/api/...`) when possible.
|
|
44
|
+
this.topicMapper = new MercureTopicMapper(apiBase, topicMode ?? 'url');
|
|
41
45
|
|
|
42
46
|
this.rebuild$
|
|
43
47
|
.pipe(
|
|
@@ -65,42 +69,54 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
65
69
|
|
|
66
70
|
|
|
67
71
|
subscribe$<T>(iris: string[], _filter?: { field?: string }): Observable<RealtimeEvent<T>> {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.topicsRegistry.addAll(iris);
|
|
73
|
-
this.scheduleRebuild();
|
|
74
|
-
|
|
75
|
-
const filterSet = new Set(iris);
|
|
76
|
-
const fieldPath = _filter?.field;
|
|
72
|
+
return defer(() => {
|
|
73
|
+
const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
74
|
+
if (inputIris.length === 0) return EMPTY;
|
|
77
75
|
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
if (!this.hubUrl) {
|
|
77
|
+
this.logger?.debug?.('[Mercure] hubUrl not configured → realtime disabled');
|
|
78
|
+
return EMPTY;
|
|
79
|
+
}
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
81
|
+
// Canonicalise topics (ref-count + URL) to avoid duplicates like:
|
|
82
|
+
// - "/api/conversations/1" and "http://localhost:8000/api/conversations/1"
|
|
83
|
+
const registeredTopics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
|
|
84
|
+
|
|
85
|
+
this.topicsRegistry.addAll(registeredTopics);
|
|
86
|
+
this.scheduleRebuild();
|
|
87
|
+
|
|
88
|
+
// Matching is done against the payload IRIs (typically "/api/...").
|
|
89
|
+
const subscribed = inputIris.map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
|
|
90
|
+
const fieldPath = _filter?.field;
|
|
91
|
+
|
|
92
|
+
return this.incoming$.pipe(
|
|
93
|
+
map((evt) => this.safeParse(evt.data)),
|
|
94
|
+
filter((raw): raw is Item => !!raw),
|
|
95
|
+
|
|
96
|
+
filter((raw: any) => {
|
|
97
|
+
if (fieldPath) {
|
|
98
|
+
const relIris = this.extractRelationIris(raw, fieldPath).map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
|
|
99
|
+
return relIris.some((relIri) => matchesAnySubscribed(relIri, subscribed));
|
|
100
|
+
}
|
|
101
|
+
const rawId = raw?.['@id'];
|
|
102
|
+
const id = typeof rawId === 'string' ? normalizeIri(this.topicMapper.toPayloadIri(rawId)) : undefined;
|
|
103
|
+
return typeof id === 'string' && matchesAnySubscribed(id, subscribed);
|
|
104
|
+
}),
|
|
105
|
+
map((payload) => ({iri: payload['@id'] as string, data: payload as T})),
|
|
106
|
+
finalize(() => {
|
|
107
|
+
this.topicsRegistry.removeAll(registeredTopics);
|
|
108
|
+
this.scheduleRebuild();
|
|
109
|
+
}),
|
|
110
|
+
share()
|
|
111
|
+
);
|
|
112
|
+
});
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
unsubscribe(iris: string[]): void {
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
117
|
+
if (inputIris.length === 0) return;
|
|
118
|
+
const topics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
|
|
119
|
+
this.topicsRegistry.removeAll(topics);
|
|
104
120
|
this.scheduleRebuild();
|
|
105
121
|
}
|
|
106
122
|
|
|
@@ -131,8 +147,14 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
131
147
|
if (this.shuttingDown) return of(void 0);
|
|
132
148
|
|
|
133
149
|
try {
|
|
150
|
+
if (!this.hubUrl) {
|
|
151
|
+
this.currentKey = undefined;
|
|
152
|
+
this._status$.next('closed');
|
|
153
|
+
return of(void 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
const hasTopics = this.topicsRegistry.hasAny();
|
|
135
|
-
const key = hasTopics ? this.topicsRegistry.computeKey(this.hubUrl, this.
|
|
157
|
+
const key = hasTopics ? this.topicsRegistry.computeKey(this.hubUrl, this.withCredentialsDefault) : undefined;
|
|
136
158
|
|
|
137
159
|
if (!hasTopics) {
|
|
138
160
|
if (this.es) this.teardownConnection();
|
|
@@ -148,7 +170,8 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
148
170
|
this.teardownConnection();
|
|
149
171
|
|
|
150
172
|
const url = this.urlBuilder.build(this.hubUrl, this.topicsRegistry.snapshot(), this.lastEventId);
|
|
151
|
-
this.
|
|
173
|
+
this.logger?.debug?.('[Mercure] connect', {hubUrl: this.hubUrl, topics: this.topicsRegistry.snapshot(), lastEventId: this.lastEventId});
|
|
174
|
+
this.es = new EventSourceWrapper(url, {withCredentials: this.withCredentialsDefault}, this.logger);
|
|
152
175
|
|
|
153
176
|
this.connectionStop$ = new Subject<void>();
|
|
154
177
|
|
|
@@ -167,7 +190,7 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
167
190
|
this.es.open();
|
|
168
191
|
this.currentKey = key;
|
|
169
192
|
} catch (err) {
|
|
170
|
-
|
|
193
|
+
this.logger?.error?.('[Mercure] rebuild failed:', err);
|
|
171
194
|
this.currentKey = undefined;
|
|
172
195
|
this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
|
|
173
196
|
}
|
|
@@ -195,12 +218,44 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
195
218
|
this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
|
|
196
219
|
}
|
|
197
220
|
|
|
198
|
-
private safeParse(raw: string): Item {
|
|
199
|
-
|
|
221
|
+
private safeParse(raw: string): Item | undefined {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(raw) as Item;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.logger?.debug?.('[Mercure] invalid JSON payload ignored', {raw});
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
200
228
|
}
|
|
201
229
|
|
|
202
|
-
private extractRelationIris(raw: any, path: string): string {
|
|
203
|
-
|
|
230
|
+
private extractRelationIris(raw: any, path: string): string[] {
|
|
231
|
+
const readPath = (obj: any, dotPath: string): any => {
|
|
232
|
+
return dotPath
|
|
233
|
+
.split('.')
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.reduce((acc, key) => acc?.[key], obj);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const normalize = (value: any): string[] => {
|
|
239
|
+
if (!value) return [];
|
|
240
|
+
if (typeof value === 'string') return value.length > 0 ? [value] : [];
|
|
241
|
+
if (typeof value === 'object' && typeof value['@id'] === 'string') return [value['@id']];
|
|
242
|
+
if (Array.isArray(value)) return value.flatMap(normalize);
|
|
243
|
+
return [];
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return normalize(readPath(raw, path));
|
|
204
247
|
}
|
|
205
248
|
|
|
206
249
|
}
|
|
250
|
+
|
|
251
|
+
function normalizeIri(iri: string): string {
|
|
252
|
+
return iri.endsWith('/') ? iri.slice(0, -1) : iri;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function matchesAnySubscribed(candidate: string, subscribed: string[]): boolean {
|
|
256
|
+
for (const iri of subscribed) {
|
|
257
|
+
if (candidate === iri) return true;
|
|
258
|
+
if (candidate.startsWith(`${iri}/`)) return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
export class RefCountTopicRegistry {
|
|
2
|
-
private readonly
|
|
2
|
+
private readonly topics = new Set<string>();
|
|
3
3
|
private readonly refCounts = new Map<string, number>();
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Increments ref-count for each topic.
|
|
7
|
+
* Callers should pass unique topic strings (deduped).
|
|
8
|
+
*/
|
|
9
|
+
addAll(topics: Iterable<string>): void {
|
|
10
|
+
for (const topic of topics) {
|
|
11
|
+
const next = (this.refCounts.get(topic) ?? 0) + 1;
|
|
12
|
+
this.refCounts.set(topic, next);
|
|
13
|
+
this.topics.add(topic);
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Decrements ref-count for each topic.
|
|
19
|
+
* Callers should pass unique topic strings (deduped).
|
|
20
|
+
*/
|
|
21
|
+
removeAll(topics: Iterable<string>): void {
|
|
22
|
+
for (const topic of topics) {
|
|
23
|
+
const next = (this.refCounts.get(topic) ?? 0) - 1;
|
|
18
24
|
if (next <= 0) {
|
|
19
|
-
this.refCounts.delete(
|
|
20
|
-
this.
|
|
25
|
+
this.refCounts.delete(topic);
|
|
26
|
+
this.topics.delete(topic);
|
|
21
27
|
} else {
|
|
22
|
-
this.refCounts.set(
|
|
28
|
+
this.refCounts.set(topic, next);
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
|
|
28
33
|
hasAny(): boolean {
|
|
29
|
-
return this.
|
|
34
|
+
return this.topics.size > 0;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
|
|
33
37
|
snapshot(): ReadonlySet<string> {
|
|
34
|
-
return new Set(this.
|
|
38
|
+
return new Set(this.topics);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
41
|
computeKey(hubUrl: string, credentialsOn: boolean): string {
|
|
40
|
-
const topicsSorted = Array.from(this.
|
|
42
|
+
const topicsSorted = Array.from(this.topics).sort().join('|');
|
|
41
43
|
return `${hubUrl}::${credentialsOn ? '1' : '0'}::${topicsSorted}`;
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public configuration types for the bridge.
|
|
3
|
+
*
|
|
4
|
+
* Keep this file dependency-free (no Angular imports) so it can be used from both
|
|
5
|
+
* runtime code and type-only contexts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface BridgeLogger {
|
|
9
|
+
debug: (...args: unknown[]) => void;
|
|
10
|
+
info: (...args: unknown[]) => void;
|
|
11
|
+
warn: (...args: unknown[]) => void;
|
|
12
|
+
error: (...args: unknown[]) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BridgeDefaults {
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
retries?:
|
|
19
|
+
| number
|
|
20
|
+
| {
|
|
21
|
+
count: number;
|
|
22
|
+
delayMs?: number;
|
|
23
|
+
methods?: string[];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Controls how Mercure topics are sent in the hub URL.
|
|
29
|
+
* - `url`: topics are absolute URLs (recommended default)
|
|
30
|
+
* - `iri`: topics are same-origin relative IRIs (e.g. `/api/...`)
|
|
31
|
+
*/
|
|
32
|
+
export type MercureTopicMode = 'url' | 'iri';
|
|
33
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {Inject, Injectable} from '@angular/core';
|
|
2
|
+
import {HttpClient} from '@angular/common/http';
|
|
3
|
+
import {Observable} from 'rxjs';
|
|
4
|
+
import {filter, map, share} from 'rxjs/operators';
|
|
5
|
+
import {toHttpParams} from '../bridge/rest/query-builder';
|
|
6
|
+
import {MercureRealtimeAdapter} from '../bridge/sse/mercure.adapter';
|
|
7
|
+
import {API_BASE_URL, BRIDGE_WITH_CREDENTIALS} from '../tokens';
|
|
8
|
+
import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, IriRequired, Item} from '../ports/resource-repository.port';
|
|
9
|
+
import {SubscribeFilter} from '../ports/realtime.port';
|
|
10
|
+
import {resolveUrl} from '../utils/url';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* High-level facade for ad-hoc HTTP calls and Mercure subscriptions.
|
|
14
|
+
*
|
|
15
|
+
* Prefer `FacadeFactory` + `ResourceFacade<T>` when you want a resource-oriented API.
|
|
16
|
+
*/
|
|
17
|
+
@Injectable({providedIn: 'root'})
|
|
18
|
+
export class BridgeFacade {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly http: HttpClient,
|
|
21
|
+
private readonly realtime: MercureRealtimeAdapter,
|
|
22
|
+
@Inject(API_BASE_URL) private readonly apiBase: string,
|
|
23
|
+
@Inject(BRIDGE_WITH_CREDENTIALS) private readonly withCredentialsDefault: boolean,
|
|
24
|
+
) {
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ──────────────── HTTP ────────────────
|
|
28
|
+
|
|
29
|
+
get$<R = unknown>(url: IriRequired, opts?: HttpCallOptions): Observable<R> {
|
|
30
|
+
return this.http.get<R>(this.resolveUrl(url), {
|
|
31
|
+
headers: opts?.headers,
|
|
32
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getCollection$<T extends Item = Item>(
|
|
37
|
+
url: IriRequired,
|
|
38
|
+
query?: AnyQuery,
|
|
39
|
+
opts?: HttpCallOptions
|
|
40
|
+
): Observable<Collection<T>> {
|
|
41
|
+
const params = toHttpParams(query);
|
|
42
|
+
return this.http.get<Collection<T>>(this.resolveUrl(url), {
|
|
43
|
+
params,
|
|
44
|
+
headers: opts?.headers,
|
|
45
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
post$<R = unknown, B = unknown>(url: IriRequired, payload: B, opts?: HttpCallOptions): Observable<R> {
|
|
50
|
+
return this.http.post<R>(this.resolveUrl(url), payload, {
|
|
51
|
+
headers: opts?.headers,
|
|
52
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
patch$<R = unknown, B = unknown>(url: IriRequired, changes: B, opts?: HttpCallOptions): Observable<R> {
|
|
57
|
+
return this.http.patch<R>(this.resolveUrl(url), changes, {
|
|
58
|
+
headers: opts?.headers,
|
|
59
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
put$<R = unknown, B = unknown>(url: IriRequired, payload: B, opts?: HttpCallOptions): Observable<R> {
|
|
64
|
+
return this.http.put<R>(this.resolveUrl(url), payload, {
|
|
65
|
+
headers: opts?.headers,
|
|
66
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
delete$(url: IriRequired, opts?: HttpCallOptions): Observable<void> {
|
|
71
|
+
return this.http.delete<void>(this.resolveUrl(url), {
|
|
72
|
+
headers: opts?.headers,
|
|
73
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R> {
|
|
78
|
+
const {method, url, query, body, headers, responseType, withCredentials, options = {}} = req;
|
|
79
|
+
const targetUrl = this.resolveUrl(url);
|
|
80
|
+
|
|
81
|
+
const mergedOptions: Record<string, unknown> = {...options};
|
|
82
|
+
if (headers) mergedOptions['headers'] = headers;
|
|
83
|
+
if (query) mergedOptions['params'] = toHttpParams(query);
|
|
84
|
+
if (body !== undefined) mergedOptions['body'] = body;
|
|
85
|
+
|
|
86
|
+
mergedOptions['responseType'] = (responseType ?? (mergedOptions['responseType'] as any) ?? 'json') as any;
|
|
87
|
+
mergedOptions['withCredentials'] =
|
|
88
|
+
withCredentials ?? (mergedOptions['withCredentials'] as any) ?? this.withCredentialsDefault;
|
|
89
|
+
mergedOptions['observe'] = 'body';
|
|
90
|
+
|
|
91
|
+
return this.http.request<R>(method, targetUrl, mergedOptions as {observe: 'body'});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ──────────────── SSE / Mercure ────────────────
|
|
95
|
+
|
|
96
|
+
watch$<T = Item>(iri: Iri | Iri[], subscribeFilter?: SubscribeFilter): Observable<T> {
|
|
97
|
+
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
98
|
+
return this.realtime
|
|
99
|
+
.subscribe$<T>(iris, subscribeFilter)
|
|
100
|
+
.pipe(
|
|
101
|
+
map((event) => event.data),
|
|
102
|
+
filter((data): data is T => data !== undefined),
|
|
103
|
+
share()
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
unwatch(iri: Iri | Iri[]): void {
|
|
108
|
+
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
109
|
+
this.realtime.unsubscribe(iris);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private resolveUrl(path?: Iri): string {
|
|
113
|
+
if (!path) throw new Error('BridgeFacade: missing url');
|
|
114
|
+
return resolveUrl(this.apiBase, path);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {EnvironmentInjector, inject, Injectable, runInInjectionContext} from '@angular/core';
|
|
2
2
|
import {HttpClient} from '@angular/common/http';
|
|
3
3
|
import {RealtimePort} from '../ports/realtime.port';
|
|
4
4
|
import {Item, ResourceRepository} from '../ports/resource-repository.port';
|
|
5
|
-
import {API_BASE_URL,
|
|
6
|
-
import {MercureRealtimeAdapter} from
|
|
7
|
-
import {ApiPlatformRestRepository} from
|
|
8
|
-
import {ResourceFacade} from
|
|
5
|
+
import {API_BASE_URL, BRIDGE_WITH_CREDENTIALS} from '../tokens';
|
|
6
|
+
import {MercureRealtimeAdapter} from '../bridge/sse/mercure.adapter';
|
|
7
|
+
import {ApiPlatformRestRepository} from '../bridge/rest/api-platform.adapter';
|
|
8
|
+
import {ResourceFacade} from './resource.facade';
|
|
9
9
|
|
|
10
10
|
export type FacadeConfig<T> = {
|
|
11
11
|
url: string;
|
|
@@ -16,21 +16,23 @@ export type FacadeConfig<T> = {
|
|
|
16
16
|
|
|
17
17
|
@Injectable({providedIn: 'root'})
|
|
18
18
|
export class FacadeFactory {
|
|
19
|
+
private readonly env = inject(EnvironmentInjector);
|
|
20
|
+
private readonly http = inject(HttpClient);
|
|
21
|
+
private readonly baseUrl = inject(API_BASE_URL);
|
|
22
|
+
private readonly withCredentials = inject(BRIDGE_WITH_CREDENTIALS);
|
|
23
|
+
private readonly mercure = inject(MercureRealtimeAdapter);
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
private readonly mercureAny: MercureRealtimeAdapter
|
|
27
|
-
) {
|
|
28
|
-
}
|
|
29
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Creates a `ResourceFacade<T>`.
|
|
27
|
+
*
|
|
28
|
+
* Important: `ResourceFacade` uses `toSignal()`, which requires an injection context.
|
|
29
|
+
* This factory ensures that by using `runInInjectionContext`.
|
|
30
|
+
*/
|
|
30
31
|
create<T extends Item>(config: FacadeConfig<T>): ResourceFacade<T> {
|
|
31
|
-
const
|
|
32
|
-
const repo = new ApiPlatformRestRepository<T>(this.http, this.baseUrl,
|
|
33
|
-
const realtime =
|
|
34
|
-
|
|
32
|
+
const url = config.url;
|
|
33
|
+
const repo = config.repo ?? new ApiPlatformRestRepository<T>(this.http, this.baseUrl, url, this.withCredentials);
|
|
34
|
+
const realtime = config.realtime ?? this.mercure;
|
|
35
|
+
|
|
36
|
+
return runInInjectionContext(this.env, () => new ResourceFacade<T>(repo, realtime));
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -1,35 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Collection,
|
|
3
|
-
CreateCommand,
|
|
4
3
|
HttpRequestConfig,
|
|
5
4
|
Iri,
|
|
5
|
+
IriRequired,
|
|
6
6
|
Item,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from
|
|
10
|
-
import {Observable} from
|
|
7
|
+
HttpCallOptions,
|
|
8
|
+
AnyQuery,
|
|
9
|
+
} from '../ports/resource-repository.port';
|
|
10
|
+
import {Observable} from 'rxjs';
|
|
11
11
|
|
|
12
12
|
export interface Facade<T extends Item> {
|
|
13
|
+
getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>>;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
get$(iri: IriRequired, opts?: HttpCallOptions): Observable<T>;
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
post$(payload: Partial<T>, opts?: HttpCallOptions): Observable<T>;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
patch$(iri: IriRequired, changes: Partial<T>, opts?: HttpCallOptions): Observable<T>;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
put$(iri: IriRequired, payload: Partial<T>, opts?: HttpCallOptions): Observable<T>;
|
|
21
22
|
|
|
22
|
-
delete$(iri:
|
|
23
|
-
|
|
24
|
-
post$<R = unknown, B = unknown>(req: RestRequest<B>): Observable<R>;
|
|
25
|
-
put$<R = unknown, B = unknown>(req: RestRequest<B>): Observable<R>;
|
|
26
|
-
patch$<R = unknown, B = unknown>(req: RestRequest<B>): Observable<R>;
|
|
23
|
+
delete$(iri: IriRequired, opts?: HttpCallOptions): Observable<void>;
|
|
27
24
|
|
|
28
25
|
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R>;
|
|
29
26
|
|
|
30
|
-
watch$(iri: Iri|Iri[]): Observable<T>;
|
|
27
|
+
watch$(iri: Iri | Iri[]): Observable<T>;
|
|
31
28
|
|
|
32
|
-
unwatch(iri: Iri|Iri[]): void;
|
|
29
|
+
unwatch(iri: Iri | Iri[]): void;
|
|
33
30
|
}
|
|
34
|
-
|
|
35
|
-
export type RestRequest<B> = Omit<HttpRequestConfig<B>, 'method'>;
|