@obsidiane/meridiane 0.0.6 → 0.0.10
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 +19 -31
- package/cli.js +64 -44
- package/package.json +3 -7
- package/templates/_lib_template/package.json +2 -1
- 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/models/index.ts +0 -1
- package/templates/_lib_template/src/public-api.ts +3 -2
- package/tools/build.js +104 -0
- package/tools/core/generate.js +152 -0
- package/tools/core/json.js +71 -0
- package/tools/core/lib-name.js +15 -0
- package/tools/core/logger.js +51 -0
- package/tools/core/paths.js +17 -0
- package/tools/core/spec.js +59 -0
- package/tools/core/standalone.js +117 -0
- package/tools/dev.js +218 -0
- package/tools/generator/models/handlebars.js +8 -2
- package/tools/generator/models/naming.js +21 -15
- package/tools/generator/models/openapi-to-models.js +75 -38
- package/tools/generator/models/schema-utils.js +84 -66
- package/tools/generator/models/templates/model.hbs +3 -3
- package/tools/generator/models/utils.js +1 -2
- package/models.config.example.js +0 -24
- package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +0 -9
- package/templates/_lib_template/src/lib/utils/utils.ts +0 -21
- package/templates/_lib_template/tsconfig.spec.json +0 -14
- package/tools/generate-lib.js +0 -136
- package/tools/generate-models.js +0 -121
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
CLI pour générer une librairie Angular “bridge” (Symfony / API Platform / Mercure) et générer des modèles TypeScript depuis une spec OpenAPI.
|
|
4
4
|
|
|
5
|
+
Documentation détaillée (repo) : `docs/index.md`.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -17,44 +19,30 @@ npm i -g @obsidiane/meridiane
|
|
|
17
19
|
## Usage
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# Générer des modèles depuis OpenAPI
|
|
24
|
-
npx meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [--out=<dir>] [--item-import=../lib/ports/resource-repository.port] [--required-mode=all-optional|spec] [--no-index]
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
`url-registry` est optionnel. Recommandé : gérer le registry via `.npmrc` / variables CI plutôt que de le passer au générateur.
|
|
22
|
+
# Dev: génère le bridge (+ models par défaut)
|
|
23
|
+
# (build standalone + install local dans node_modules)
|
|
24
|
+
npx meridiane dev <packageName> --spec <url|file> [--preset[=native|all]] [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
npm ci
|
|
33
|
-
npx -y @obsidiane/meridiane@0.1.0 lib backend-bridge @acme/backend-bridge 0.1.0
|
|
34
|
-
npx -y @obsidiane/meridiane@0.1.0 models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
|
|
35
|
-
npx ng build backend-bridge
|
|
36
|
-
cd dist/backend-bridge
|
|
37
|
-
npm publish
|
|
26
|
+
# CI/CD: génère + build Angular + npm pack (artefact prêt à publier)
|
|
27
|
+
npx meridiane build <packageName> --version <semver> --spec <url|file> [--preset[=native|all]] [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
|
|
38
28
|
```
|
|
39
29
|
|
|
40
|
-
|
|
30
|
+
Options :
|
|
31
|
+
- `--debug` : active des logs supplémentaires (CLI).
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
Bonnes pratiques :
|
|
34
|
+
- utiliser `--preset=native` si vous voulez des modèles “entity-like” (sans variantes `.jsonld/.jsonMergePatch`)
|
|
35
|
+
- utiliser `--no-models` si vous voulez uniquement le runtime (pas besoin de `--spec`)
|
|
36
|
+
- laisser le registry à la CI (`.npmrc`, variables d’environnement, `npm publish --registry …`)
|
|
45
37
|
|
|
46
|
-
|
|
38
|
+
Note (repo Meridiane) :
|
|
39
|
+
- dans le repo Meridiane, `meridiane dev` peut être exécutée sans `packageName` et se préconfigure pour `apps/sandbox` (`@obsidiane/bridge-sandbox`).
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Copie rapide (après installation) :
|
|
41
|
+
## Exemple CI (générer + build + publish)
|
|
51
42
|
|
|
52
43
|
```bash
|
|
53
|
-
|
|
44
|
+
npx -y @obsidiane/meridiane@0.1.0 build @acme/backend-bridge --version 0.1.0 --spec https://staging.example/api/docs.json --preset=native
|
|
45
|
+
npm publish dist/backend-bridge
|
|
54
46
|
```
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
npx -y @obsidiane/meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
|
|
60
|
-
```
|
|
48
|
+
Note : Meridiane installe le toolchain (`ng-packagr`, `@angular/*`, …) dans `dist/.meridiane-workspace` si nécessaire.
|
package/cli.js
CHANGED
|
@@ -1,50 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
process.exit(code ?? 0);
|
|
17
|
-
});
|
|
18
|
-
}
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
|
|
5
|
+
import { runDev } from './tools/dev.js';
|
|
6
|
+
import { runBuild } from './tools/build.js';
|
|
7
|
+
import { createLogger } from './tools/core/logger.js';
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('meridiane')
|
|
13
|
+
.description('Generate an Angular bridge + models from an API Platform OpenAPI spec.')
|
|
14
|
+
.showHelpAfterError()
|
|
15
|
+
.showSuggestionAfterError();
|
|
19
16
|
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [options]
|
|
17
|
+
function presetParser(value, previous) {
|
|
18
|
+
if (value === undefined && previous === undefined) return true; // `--preset` (no value) => native
|
|
19
|
+
return value ?? previous;
|
|
20
|
+
}
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
npx -y @obsidiane/meridiane models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
|
|
29
|
-
`);
|
|
22
|
+
function collect(value, previous = []) {
|
|
23
|
+
return [...previous, value];
|
|
30
24
|
}
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
break;
|
|
41
|
-
case '-h':
|
|
42
|
-
case '--help':
|
|
43
|
-
case undefined:
|
|
44
|
-
printHelp();
|
|
45
|
-
break;
|
|
46
|
-
default:
|
|
47
|
-
console.error(`Unknown command: ${cmd}`);
|
|
48
|
-
printHelp();
|
|
49
|
-
process.exit(1);
|
|
26
|
+
function commonOptions(cmd) {
|
|
27
|
+
return cmd
|
|
28
|
+
.option('--spec <urlOrFile>', 'OpenAPI spec source (URL or local JSON file)')
|
|
29
|
+
.option('--preset [mode]', 'Preset: native|all (default: all; `--preset` alone => native)', presetParser)
|
|
30
|
+
.option('--include <substr>', 'Include schema names containing substring (repeatable, supports commas)', collect, [])
|
|
31
|
+
.option('--exclude <substr>', 'Exclude schema names containing substring (repeatable, supports commas)', collect, [])
|
|
32
|
+
.option('--no-models', 'Skip models generation (then --spec is not required)')
|
|
33
|
+
.option('--debug', 'Enable debug logs');
|
|
50
34
|
}
|
|
35
|
+
|
|
36
|
+
commonOptions(
|
|
37
|
+
program
|
|
38
|
+
.command('dev')
|
|
39
|
+
.argument(
|
|
40
|
+
'[packageName]',
|
|
41
|
+
'NPM package name of the generated bridge (e.g. @acme/backend-bridge). Optional in this repo (defaults to @obsidiane/bridge-sandbox).'
|
|
42
|
+
)
|
|
43
|
+
.action(async (packageName, opts) => {
|
|
44
|
+
const log = createLogger({ debug: !!opts?.debug });
|
|
45
|
+
try {
|
|
46
|
+
await runDev(packageName, opts);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.error(err);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
commonOptions(
|
|
55
|
+
program
|
|
56
|
+
.command('build')
|
|
57
|
+
.argument('<packageName>', 'NPM package name of the generated bridge (e.g. @acme/backend-bridge)')
|
|
58
|
+
.requiredOption('--version <semver>', 'Version to write in the generated bridge package.json (CI/CD)')
|
|
59
|
+
.action(async (packageName, opts) => {
|
|
60
|
+
const log = createLogger({ debug: !!opts?.debug });
|
|
61
|
+
try {
|
|
62
|
+
await runBuild(packageName, opts);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.error(err);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsidiane/meridiane",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Générateur de librairie Angular et modèles TypeScript (OpenAPI) pour un bridge Symfony/API Platform + Mercure.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,24 +14,20 @@
|
|
|
14
14
|
"pack:check": "npm_config_cache=./.npm-cache npm pack --dry-run"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"meridiane": "cli.js"
|
|
18
|
-
"meridiane-generate-lib": "tools/generate-lib.js",
|
|
19
|
-
"meridiane-generate-models": "tools/generate-models.js"
|
|
17
|
+
"meridiane": "cli.js"
|
|
20
18
|
},
|
|
21
19
|
"files": [
|
|
22
20
|
"cli.js",
|
|
23
21
|
"tools/**",
|
|
24
22
|
"templates/**",
|
|
25
|
-
"models.config.example.js",
|
|
26
23
|
"README.md"
|
|
27
24
|
],
|
|
28
25
|
"engines": {
|
|
29
26
|
"node": ">=18"
|
|
30
27
|
},
|
|
31
28
|
"dependencies": {
|
|
32
|
-
"
|
|
29
|
+
"commander": "^14.0.2",
|
|
33
30
|
"handlebars": "^4.7.8",
|
|
34
|
-
"openapi-typescript-codegen": "^0.29.0",
|
|
35
31
|
"tslib": "^2.3.0"
|
|
36
32
|
}
|
|
37
33
|
}
|
|
@@ -3,60 +3,71 @@ import {Observable} from 'rxjs';
|
|
|
3
3
|
import {toHttpParams} from './query-builder';
|
|
4
4
|
import {
|
|
5
5
|
Collection,
|
|
6
|
-
|
|
6
|
+
HttpCallOptions,
|
|
7
7
|
HttpRequestConfig,
|
|
8
|
-
Iri,
|
|
9
|
-
|
|
8
|
+
Iri,
|
|
9
|
+
IriRequired,
|
|
10
|
+
Item,
|
|
11
|
+
AnyQuery,
|
|
10
12
|
ResourceRepository,
|
|
11
|
-
UpdateCommand
|
|
12
13
|
} from '../../ports/resource-repository.port';
|
|
13
|
-
import {
|
|
14
|
-
|
|
14
|
+
import {resolveUrl} from '../../utils/url';
|
|
15
15
|
|
|
16
16
|
export class ApiPlatformRestRepository<T extends Item> implements ResourceRepository<T> {
|
|
17
|
-
private readonly credentialsPolicy: CredentialsPolicy;
|
|
18
|
-
|
|
19
17
|
constructor(
|
|
20
18
|
private readonly http: HttpClient,
|
|
21
19
|
private readonly apiBase: string,
|
|
22
20
|
private readonly resourcePath: Iri,
|
|
23
|
-
readonly
|
|
21
|
+
private readonly withCredentialsDefault: boolean,
|
|
24
22
|
) {
|
|
25
|
-
this.credentialsPolicy = new CredentialsPolicy(init);
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>> {
|
|
26
|
+
const params = toHttpParams(query);
|
|
27
|
+
return this.http.get<Collection<T>>(this.resolveUrl(this.resourcePath), {
|
|
28
|
+
params,
|
|
29
|
+
headers: opts?.headers,
|
|
30
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
32
31
|
});
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
get$(iri:
|
|
36
|
-
return this.http.get<T>(
|
|
37
|
-
|
|
34
|
+
get$(iri: IriRequired, opts?: HttpCallOptions): Observable<T> {
|
|
35
|
+
return this.http.get<T>(this.resolveUrl(iri), {
|
|
36
|
+
headers: opts?.headers,
|
|
37
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
return this.http.post<T>(
|
|
43
|
-
|
|
41
|
+
post$(payload: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
42
|
+
return this.http.post<T>(this.resolveUrl(this.resourcePath), payload, {
|
|
43
|
+
headers: opts?.headers,
|
|
44
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
return this.http.patch<T>(
|
|
49
|
-
|
|
48
|
+
patch$(iri: IriRequired, changes: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
49
|
+
return this.http.patch<T>(this.resolveUrl(iri), changes, {
|
|
50
|
+
headers: opts?.headers,
|
|
51
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
return this.http.
|
|
55
|
-
|
|
55
|
+
put$(iri: IriRequired, payload: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
56
|
+
return this.http.put<T>(this.resolveUrl(iri), payload, {
|
|
57
|
+
headers: opts?.headers,
|
|
58
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
delete$(iri: IriRequired, opts?: HttpCallOptions): Observable<void> {
|
|
63
|
+
return this.http.delete<void>(this.resolveUrl(iri), {
|
|
64
|
+
headers: opts?.headers,
|
|
65
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
56
66
|
});
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R> {
|
|
70
|
+
// Low-level escape hatch for non-standard endpoints (custom controllers, uploads, etc.).
|
|
60
71
|
const {
|
|
61
72
|
method,
|
|
62
73
|
url,
|
|
@@ -65,37 +76,27 @@ export class ApiPlatformRestRepository<T extends Item> implements ResourceReposi
|
|
|
65
76
|
headers,
|
|
66
77
|
responseType,
|
|
67
78
|
withCredentials,
|
|
68
|
-
options = {}
|
|
79
|
+
options = {},
|
|
69
80
|
} = req;
|
|
70
81
|
|
|
71
|
-
const mergedOptions: any = {...options};
|
|
72
82
|
const targetUrl = this.resolveUrl(url ?? this.resourcePath);
|
|
83
|
+
const mergedOptions: Record<string, unknown> = {...options};
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
mergedOptions.responseType = computedResponseType as any;
|
|
85
|
+
mergedOptions['responseType'] = (responseType ?? (mergedOptions['responseType'] as any) ?? 'json') as any;
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
mergedOptions.withCredentials = credentials;
|
|
87
|
+
mergedOptions['withCredentials'] = withCredentials ?? (mergedOptions['withCredentials'] as any) ?? this.withCredentialsDefault;
|
|
79
88
|
|
|
80
|
-
if (headers) mergedOptions
|
|
81
|
-
if (query) mergedOptions
|
|
82
|
-
if (body !== undefined) mergedOptions
|
|
89
|
+
if (headers) mergedOptions['headers'] = headers;
|
|
90
|
+
if (query) mergedOptions['params'] = toHttpParams(query);
|
|
91
|
+
if (body !== undefined) mergedOptions['body'] = body;
|
|
83
92
|
|
|
84
|
-
mergedOptions
|
|
85
|
-
return this.http.request<R>(method, targetUrl, mergedOptions as {
|
|
93
|
+
mergedOptions['observe'] = 'body';
|
|
94
|
+
return this.http.request<R>(method, targetUrl, mergedOptions as {observe: 'body'});
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
private resolveUrl(path?: Iri): string {
|
|
89
98
|
const effectivePath = path ?? this.resourcePath;
|
|
90
|
-
if (!effectivePath) throw new Error('ApiPlatformRestRepository
|
|
91
|
-
|
|
92
|
-
if (effectivePath.startsWith('//')) return effectivePath;
|
|
93
|
-
return joinUrl(this.apiBase, effectivePath);
|
|
99
|
+
if (!effectivePath) throw new Error('ApiPlatformRestRepository: missing url and resourcePath');
|
|
100
|
+
return resolveUrl(this.apiBase, effectivePath);
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
|
-
|
|
97
|
-
function joinUrl(base: string, path: string): string {
|
|
98
|
-
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
99
|
-
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
100
|
-
return `${normalizedBase}${normalizedPath}`;
|
|
101
|
-
}
|
|
@@ -2,42 +2,42 @@ import {HttpParams} from '@angular/common/http';
|
|
|
2
2
|
import {AnyQuery, Query, QueryParamValue} from '../../ports/resource-repository.port';
|
|
3
3
|
|
|
4
4
|
export function toHttpParams(q: AnyQuery | undefined): HttpParams {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
if (!q) return new HttpParams();
|
|
6
|
+
if (q instanceof HttpParams) return q;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const fromObject: Record<string, string | string[]> = {};
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
const consumed = new Set<string>();
|
|
11
|
+
const maybeQuery = q as Query;
|
|
12
|
+
if (maybeQuery.page != null) {
|
|
13
|
+
fromObject['page'] = String(maybeQuery.page);
|
|
14
|
+
consumed.add('page');
|
|
15
|
+
}
|
|
16
|
+
if (maybeQuery.itemsPerPage != null) {
|
|
17
|
+
fromObject['itemsPerPage'] = String(maybeQuery.itemsPerPage);
|
|
18
|
+
consumed.add('itemsPerPage');
|
|
19
|
+
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
21
|
+
if (q.filters) {
|
|
22
|
+
consumed.add('filters');
|
|
23
|
+
for (const [k, v] of Object.entries(q.filters)) {
|
|
24
|
+
assign(fromObject, k, v);
|
|
26
25
|
}
|
|
26
|
+
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
for (const [k, v] of Object.entries(q as Record<string, QueryParamValue>)) {
|
|
29
|
+
if (consumed.has(k)) continue;
|
|
30
|
+
assign(fromObject, k, v);
|
|
31
|
+
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
return new HttpParams({fromObject});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function assign(target: Record<string, string | string[]>, key: string, value: QueryParamValue | undefined) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
if (value == null) return;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
target[key] = value.map(String);
|
|
40
|
+
} else {
|
|
41
|
+
target[key] = String(value);
|
|
42
|
+
}
|
|
43
43
|
}
|
|
@@ -1,74 +1,70 @@
|
|
|
1
1
|
import {ReplaySubject, Subject} from 'rxjs';
|
|
2
2
|
import {SseEvent, SseOptions, RealtimeStatus} from '../../ports/realtime.port';
|
|
3
|
+
import {BridgeLogger} from '../../bridge.types';
|
|
3
4
|
|
|
4
5
|
export class EventSourceWrapper {
|
|
5
|
-
|
|
6
|
+
private es?: EventSource;
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
private readonly statusSub = new ReplaySubject<RealtimeStatus>(1);
|
|
9
|
+
private readonly eventSub = new Subject<SseEvent>();
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
readonly status$ = this.statusSub.asObservable();
|
|
12
|
+
readonly events$ = this.eventSub.asObservable();
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
private readonly
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly url: string,
|
|
16
|
+
private readonly opts: SseOptions = {},
|
|
17
|
+
private readonly logger?: BridgeLogger,
|
|
18
|
+
) {
|
|
19
|
+
this.setState('closed');
|
|
20
|
+
this.log('[SSE] init', {url, withCredentials: !!opts.withCredentials});
|
|
21
|
+
}
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.setState('closed');
|
|
21
|
-
this.log('[SSE] initialized', url, opts);
|
|
23
|
+
open(): void {
|
|
24
|
+
if (this.es) {
|
|
25
|
+
this.log('[SSE] open() ignored: already open');
|
|
26
|
+
return;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this.log('[SSE] open() ignored: already open');
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
+
this.setState('connecting');
|
|
30
|
+
this.log('[SSE] open', {url: this.url});
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const es = new EventSource(this.url, {
|
|
33
|
+
withCredentials: !!this.opts.withCredentials,
|
|
34
|
+
});
|
|
35
|
+
this.es = es;
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.es = es;
|
|
37
|
+
es.onopen = () => {
|
|
38
|
+
this.setState('connected');
|
|
39
|
+
};
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
es.onmessage = (ev) => {
|
|
42
|
+
this.eventSub.next({type: 'message', data: ev.data, lastEventId: ev.lastEventId || undefined});
|
|
43
|
+
};
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
es.onerror = () => {
|
|
46
|
+
// The browser will retry automatically. We stay in "connecting".
|
|
47
|
+
this.log('[SSE] error');
|
|
48
|
+
this.setState('connecting');
|
|
49
|
+
};
|
|
50
|
+
}
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
close(): void {
|
|
53
|
+
if (this.es) {
|
|
54
|
+
this.es.close();
|
|
55
|
+
this.es = undefined;
|
|
56
|
+
this.log('[SSE] closed');
|
|
53
57
|
}
|
|
58
|
+
this.setState('closed');
|
|
59
|
+
}
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
if (this.es) {
|
|
57
|
-
this.es.close();
|
|
58
|
-
this.es = undefined;
|
|
59
|
-
this.log('[SSE] closed');
|
|
60
|
-
}
|
|
61
|
-
this.setState('closed');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ──────────────── internals ────────────────
|
|
61
|
+
// ──────────────── internals ────────────────
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
63
|
+
private setState(state: RealtimeStatus): void {
|
|
64
|
+
this.statusSub.next(state);
|
|
65
|
+
}
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
private log(...args: unknown[]): void {
|
|
68
|
+
this.logger?.debug?.(...args);
|
|
69
|
+
}
|
|
74
70
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {MercureTopicMode} from '../../bridge.types';
|
|
2
|
+
|
|
3
|
+
export class MercureTopicMapper {
|
|
4
|
+
private readonly apiBaseUrl: URL;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
apiBase: string,
|
|
8
|
+
private readonly mode: MercureTopicMode,
|
|
9
|
+
) {
|
|
10
|
+
this.apiBaseUrl = new URL(apiBase);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Canonical value used for ref-counting and as the "topic" query param value.
|
|
15
|
+
* - mode "url": always absolute, same-origin resolved
|
|
16
|
+
* - mode "iri": same-origin path+query+hash ("/api/..."), otherwise keep as-is
|
|
17
|
+
*/
|
|
18
|
+
toTopic(input: string): string {
|
|
19
|
+
if (this.mode === 'url') return this.toAbsoluteUrl(input);
|
|
20
|
+
return this.toRelativeIriIfSameOrigin(input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Canonical value used to compare incoming payload IRIs with subscribed IRIs.
|
|
25
|
+
* We keep payload matching stable by using same-origin relative IRIs ("/api/...").
|
|
26
|
+
*/
|
|
27
|
+
toPayloadIri(input: string): string {
|
|
28
|
+
return this.toRelativeIriIfSameOrigin(input);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private toAbsoluteUrl(input: string): string {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(input, this.apiBaseUrl).toString();
|
|
34
|
+
} catch {
|
|
35
|
+
return input;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private toRelativeIriIfSameOrigin(input: string): string {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(input, this.apiBaseUrl);
|
|
42
|
+
if (url.origin !== this.apiBaseUrl.origin) return input;
|
|
43
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
44
|
+
} catch {
|
|
45
|
+
return input;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
export class MercureUrlBuilder {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
build(hubUrl: string,
|
|
2
|
+
/**
|
|
3
|
+
* Builds the Mercure hub URL with one `topic=` parameter per topic.
|
|
4
|
+
* The adapter is responsible for canonicalising topics beforehand.
|
|
5
|
+
*/
|
|
6
|
+
build(hubUrl: string, topics: ReadonlySet<string>, lastEventId?: string): string {
|
|
7
7
|
const url = new URL(hubUrl);
|
|
8
8
|
if (lastEventId) {
|
|
9
9
|
url.searchParams.set('lastEventID', lastEventId);
|
|
10
10
|
}
|
|
11
11
|
url.searchParams.delete('topic');
|
|
12
|
-
for (const
|
|
13
|
-
url.searchParams.append('topic',
|
|
12
|
+
for (const topic of topics) {
|
|
13
|
+
url.searchParams.append('topic', topic);
|
|
14
14
|
}
|
|
15
15
|
return url.toString();
|
|
16
16
|
}
|