@linklabjs/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +411 -0
- package/package.json +48 -0
- package/src/api/DomainNode.ts +1433 -0
- package/src/api/Graph.ts +271 -0
- package/src/api/PathBuilder.ts +247 -0
- package/src/api/index.ts +15 -0
- package/src/api/loadGraph.ts +207 -0
- package/src/api/test-api.ts +153 -0
- package/src/api/test-domain.ts +119 -0
- package/src/api/types.ts +88 -0
- package/src/config/synonyms.json +28 -0
- package/src/core/EventBus.ts +187 -0
- package/src/core/GraphEvents.ts +153 -0
- package/src/core/PathFinder.ts +283 -0
- package/src/formatters/BaseFormatter.ts +17 -0
- package/src/graph/GraphAssembler.ts +50 -0
- package/src/graph/GraphCompiler.ts +412 -0
- package/src/graph/GraphExtractor.ts +191 -0
- package/src/graph/GraphOptimizer.ts +404 -0
- package/src/graph/GraphTrainer.ts +247 -0
- package/src/http/LinkBuilder.ts +244 -0
- package/src/http/TrailRequest.ts +48 -0
- package/src/http/example-netflix.ts +59 -0
- package/src/http/hateoas/README.md +87 -0
- package/src/http/index.ts +33 -0
- package/src/http/plugin.ts +360 -0
- package/src/index.ts +121 -0
- package/src/instrumentation/TelemetryShim.ts +172 -0
- package/src/navigation/NavigationEngine.ts +441 -0
- package/src/navigation/Resolver.ts +134 -0
- package/src/navigation/Scheduler.ts +136 -0
- package/src/navigation/Trail.ts +252 -0
- package/src/navigation/TrailParser.ts +207 -0
- package/src/navigation/index.ts +11 -0
- package/src/providers/MockProvider.ts +68 -0
- package/src/providers/PostgresProvider.ts +187 -0
- package/src/runtime/CompiledGraphEngine.ts +274 -0
- package/src/runtime/DataLoader.ts +236 -0
- package/src/runtime/Engine.ts +163 -0
- package/src/runtime/QueryEngine.ts +222 -0
- package/src/scenarios/test-metro-paris/config.json +6 -0
- package/src/scenarios/test-metro-paris/graph.json +16325 -0
- package/src/scenarios/test-metro-paris/queries.ts +152 -0
- package/src/scenarios/test-metro-paris/stack.json +1 -0
- package/src/scenarios/test-musicians/config.json +10 -0
- package/src/scenarios/test-musicians/graph.json +20 -0
- package/src/scenarios/test-musicians/stack.json +1 -0
- package/src/scenarios/test-netflix/MIGRATION.md +23 -0
- package/src/scenarios/test-netflix/README.md +138 -0
- package/src/scenarios/test-netflix/actions.ts +92 -0
- package/src/scenarios/test-netflix/config.json +6 -0
- package/src/scenarios/test-netflix/data/categories.json +1 -0
- package/src/scenarios/test-netflix/data/companies.json +1 -0
- package/src/scenarios/test-netflix/data/credits.json +19797 -0
- package/src/scenarios/test-netflix/data/departments.json +18 -0
- package/src/scenarios/test-netflix/data/jobs.json +142 -0
- package/src/scenarios/test-netflix/data/movies.json +3497 -0
- package/src/scenarios/test-netflix/data/people.json +1 -0
- package/src/scenarios/test-netflix/data/synonyms.json +8 -0
- package/src/scenarios/test-netflix/data/users.json +70 -0
- package/src/scenarios/test-netflix/graph.json +1017 -0
- package/src/scenarios/test-netflix/queries.ts +159 -0
- package/src/scenarios/test-netflix/stack.json +14 -0
- package/src/schema/GraphBuilder.ts +106 -0
- package/src/schema/JsonSchemaExtractor.ts +107 -0
- package/src/schema/SchemaAnalyzer.ts +175 -0
- package/src/schema/SchemaExtractor.ts +102 -0
- package/src/schema/SynonymResolver.ts +143 -0
- package/src/scripts/dictionary.json +796 -0
- package/src/scripts/graph.json +664 -0
- package/src/scripts/regenerate.ts +248 -0
- package/src/types/index.ts +506 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkBuilder — Génère les liens HATEOAS depuis le graphe
|
|
3
|
+
*
|
|
4
|
+
* Logique pure, sans dépendance à Fastify.
|
|
5
|
+
* Prend un Trail + un Graph, retourne des liens navigables.
|
|
6
|
+
*
|
|
7
|
+
* Trois catégories de liens générés automatiquement :
|
|
8
|
+
*
|
|
9
|
+
* self — l'URL courante (Trail sérialisé)
|
|
10
|
+
* up — le parent (Trail sans la dernière frame)
|
|
11
|
+
* relations — toutes les arêtes sortantes du nœud courant
|
|
12
|
+
*
|
|
13
|
+
* Les liens émergent du graphe — le dev ne configure rien.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Graph, GraphEdge } from '../types/index.js'
|
|
17
|
+
import { Trail } from '../navigation/Trail.js'
|
|
18
|
+
import { TrailParser } from '../navigation/TrailParser.js'
|
|
19
|
+
|
|
20
|
+
// ── Types ─────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface HateoasLink {
|
|
23
|
+
href: string
|
|
24
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
25
|
+
templated?: boolean // true si l'href contient {id}
|
|
26
|
+
title?: string // label lisible — ex: "Films de Nolan"
|
|
27
|
+
rel?: string // relation sémantique — ex: "movies"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HateoasLinks {
|
|
31
|
+
self: HateoasLink
|
|
32
|
+
up?: HateoasLink
|
|
33
|
+
[relation: string]: HateoasLink | undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LinkBuilderOptions {
|
|
37
|
+
/** Préfixe ajouté à toutes les URLs générées — ex: '/api/v1' */
|
|
38
|
+
prefix?: string
|
|
39
|
+
/** Inclure les arêtes inverses (retour vers le parent) */
|
|
40
|
+
includeReverse?: boolean
|
|
41
|
+
/** Exclure certaines relations — ex: ['internal', 'debug'] */
|
|
42
|
+
exclude?: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── LinkBuilder ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export class LinkBuilder {
|
|
48
|
+
private graph: Graph
|
|
49
|
+
private options: Required<LinkBuilderOptions>
|
|
50
|
+
|
|
51
|
+
constructor(graph: Graph, options: LinkBuilderOptions = {}) {
|
|
52
|
+
this.graph = graph
|
|
53
|
+
this.options = {
|
|
54
|
+
prefix: options.prefix ?? '',
|
|
55
|
+
includeReverse: options.includeReverse ?? false,
|
|
56
|
+
exclude: options.exclude ?? [],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Génère les liens HATEOAS pour un Trail donné.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* const builder = new LinkBuilder(graph, { prefix: '/api' })
|
|
66
|
+
* const links = builder.from(trail)
|
|
67
|
+
* // {
|
|
68
|
+
* // self: { href: '/api/people/Nolan/movies' },
|
|
69
|
+
* // up: { href: '/api/people/Nolan' },
|
|
70
|
+
* // actors: { href: '/api/people/Nolan/movies/{id}/actors', templated: true },
|
|
71
|
+
* // ratings: { href: '/api/people/Nolan/movies/{id}/ratings', templated: true }
|
|
72
|
+
* // }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
from(trail: Trail): HateoasLinks {
|
|
76
|
+
const currentPath = this.prefix(TrailParser.toPath(trail))
|
|
77
|
+
|
|
78
|
+
const links: HateoasLinks = {
|
|
79
|
+
self: {
|
|
80
|
+
href: currentPath,
|
|
81
|
+
method: 'GET',
|
|
82
|
+
rel: 'self',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Frame courante ────────────────────────────────────────
|
|
87
|
+
const current = trail.current
|
|
88
|
+
|
|
89
|
+
// ── Lien "up" — parent dans le Trail ou collection ─────────
|
|
90
|
+
// depth = 1 avec id : up vers la collection (ex: /movies/278 → up: /movies)
|
|
91
|
+
// depth > 1 : up vers l'entité parente (ex: /movies/278/people → up: /movies/278)
|
|
92
|
+
if (trail.depth === 1 && current?.id !== undefined) {
|
|
93
|
+
links.up = {
|
|
94
|
+
href: this.prefix('/' + current.entity),
|
|
95
|
+
method: 'GET',
|
|
96
|
+
rel: 'up',
|
|
97
|
+
title: `Collection ${current.entity}`,
|
|
98
|
+
}
|
|
99
|
+
} else if (trail.depth > 1) {
|
|
100
|
+
const parentTrail = trail.slice(trail.depth - 1)
|
|
101
|
+
links.up = {
|
|
102
|
+
href: this.prefix(TrailParser.toPath(parentTrail)),
|
|
103
|
+
method: 'GET',
|
|
104
|
+
rel: 'up',
|
|
105
|
+
title: `Retour vers ${parentTrail.current?.entity}`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Liens sortants — arêtes depuis le nœud courant ────────
|
|
110
|
+
if (!current) return links
|
|
111
|
+
|
|
112
|
+
const outgoing = this.getOutgoingEdges(current.entity)
|
|
113
|
+
|
|
114
|
+
// Grouper les arêtes par entité cible (.to)
|
|
115
|
+
// Plusieurs arêtes peuvent pointer vers la même entité (ex: actor, director, writer → people)
|
|
116
|
+
// On génère un seul lien par entité cible, avec l'arête de poids minimal
|
|
117
|
+
const byTarget = new Map<string, GraphEdge>()
|
|
118
|
+
for (const edge of outgoing) {
|
|
119
|
+
// Ignorer les relations exclues
|
|
120
|
+
if (this.options.exclude.includes(edge.name ?? '')) continue
|
|
121
|
+
if (this.options.exclude.includes(edge.to)) continue
|
|
122
|
+
|
|
123
|
+
const edgeLabel = (edge as any).label ?? edge.name ?? ''
|
|
124
|
+
// Préférer une edge avec un label significatif (pas 'unknow', pas vide)
|
|
125
|
+
const isSignificant = edgeLabel && edgeLabel !== 'unknow'
|
|
126
|
+
|
|
127
|
+
const existing = byTarget.get(edge.to)
|
|
128
|
+
if (!existing) {
|
|
129
|
+
byTarget.set(edge.to, edge)
|
|
130
|
+
} else {
|
|
131
|
+
const existingLabel = (existing as any).label ?? existing.name ?? ''
|
|
132
|
+
const existingSignificant = existingLabel && existingLabel !== 'unknow'
|
|
133
|
+
// Remplacer si : l'actuelle est insignifiante ET la nouvelle est significative,
|
|
134
|
+
// ou les deux sont significatives et la nouvelle est moins lourde
|
|
135
|
+
if ((!existingSignificant && isSignificant) ||
|
|
136
|
+
(existingSignificant && isSignificant && edge.weight < existing.weight)) {
|
|
137
|
+
byTarget.set(edge.to, edge)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const [targetEntity, edge] of byTarget) {
|
|
143
|
+
// L'URL utilise l'entité cible comme segment — pas le nom de l'arête
|
|
144
|
+
// /api/movies/278/people (pas /api/movies/278/actor)
|
|
145
|
+
const href = current.id !== undefined
|
|
146
|
+
? this.prefix(TrailParser.toPath(trail) + '/' + targetEntity)
|
|
147
|
+
: this.prefix(TrailParser.toPath(trail) + '/{id}/' + targetEntity)
|
|
148
|
+
|
|
149
|
+
const templated = current.id === undefined || href.includes('{id}')
|
|
150
|
+
|
|
151
|
+
links[targetEntity] = {
|
|
152
|
+
href,
|
|
153
|
+
method: 'GET',
|
|
154
|
+
templated: templated || undefined,
|
|
155
|
+
rel: targetEntity,
|
|
156
|
+
title: this.buildTitle(trail, edge),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return links
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Génère les liens pour une collection de résultats.
|
|
165
|
+
* Chaque item reçoit ses propres liens self + relations.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```typescript
|
|
169
|
+
* // GET /people/Nolan/movies → liste de films
|
|
170
|
+
* const itemLinks = builder.forItems(trail, movies, 'id')
|
|
171
|
+
* // itemLinks[0] = { self: { href: '/people/Nolan/movies/1' }, actors: {...} }
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
forItems(
|
|
175
|
+
trail: Trail,
|
|
176
|
+
items: any[],
|
|
177
|
+
idField: string = 'id'
|
|
178
|
+
): HateoasLinks[] {
|
|
179
|
+
return items.map(item => {
|
|
180
|
+
const id = item[idField]
|
|
181
|
+
if (id === undefined) return this.from(trail)
|
|
182
|
+
|
|
183
|
+
// Construire un Trail avec l'id de l'item
|
|
184
|
+
const itemTrail = trail.clone()
|
|
185
|
+
const last = itemTrail.current
|
|
186
|
+
if (last) {
|
|
187
|
+
// Remplacer la dernière frame avec l'id
|
|
188
|
+
itemTrail.pop()
|
|
189
|
+
itemTrail.push({ ...last, id, state: 'RESOLVED' })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return this.from(itemTrail)
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Vérifie si une relation existe depuis un nœud donné.
|
|
198
|
+
* Utile pour les hooks d'access.check.
|
|
199
|
+
*/
|
|
200
|
+
hasRelation(fromEntity: string, relation: string): boolean {
|
|
201
|
+
return this.graph.edges.some(
|
|
202
|
+
e => e.from === fromEntity && (e.name === relation || e.to === relation)
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Retourne toutes les entités accessibles depuis un nœud.
|
|
208
|
+
*/
|
|
209
|
+
reachableFrom(entity: string): string[] {
|
|
210
|
+
return this.getOutgoingEdges(entity).map(e => e.name ?? e.to)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Privé ──────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
private getOutgoingEdges(entity: string): GraphEdge[] {
|
|
216
|
+
return this.graph.edges
|
|
217
|
+
.filter(e => e.from === entity)
|
|
218
|
+
.sort((a, b) => b.weight - a.weight) // les plus utilisées en premier
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private prefix(path: string): string {
|
|
222
|
+
if (!this.options.prefix) return path
|
|
223
|
+
return this.options.prefix.replace(/\/$/, '') + path
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private buildTitle(trail: Trail, edge: GraphEdge): string {
|
|
227
|
+
const current = trail.current
|
|
228
|
+
|
|
229
|
+
// Résoudre un label lisible pour l'edge :
|
|
230
|
+
// 1. edge.name explicite et non générique (ex: 'LIST_OF_CREDITS', 'actor')
|
|
231
|
+
// 2. edge.to comme fallback neutre (ex: 'people', 'movies')
|
|
232
|
+
const rawName = edge.name ?? ''
|
|
233
|
+
const isGeneric = !rawName || rawName === 'unknow' || rawName === 'unknow_in'
|
|
234
|
+
const edgeLabel = isGeneric ? edge.to : rawName
|
|
235
|
+
|
|
236
|
+
if (!current) return edgeLabel
|
|
237
|
+
|
|
238
|
+
const from = current.id !== undefined
|
|
239
|
+
? `${current.entity}(${current.id})`
|
|
240
|
+
: current.entity
|
|
241
|
+
|
|
242
|
+
return `${edgeLabel} de ${from}`
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TrailRequest — Augmentation du type Request Fastify
|
|
3
|
+
*
|
|
4
|
+
* Ajoute `request.trail` et `request.linkBuilder`
|
|
5
|
+
* sur chaque requête décorée par le plugin LinkLab.
|
|
6
|
+
*
|
|
7
|
+
* Usage :
|
|
8
|
+
* ```typescript
|
|
9
|
+
* fastify.get('/*', async (req, reply) => {
|
|
10
|
+
* const trail = req.trail // Trail parsé depuis l'URL
|
|
11
|
+
* const links = req.linkBuilder // LinkBuilder prêt à l'emploi
|
|
12
|
+
* })
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FastifyRequest } from 'fastify'
|
|
17
|
+
import type { Trail } from '../navigation/Trail.js'
|
|
18
|
+
import type { LinkBuilder } from './LinkBuilder.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Déclaration d'augmentation du module Fastify.
|
|
22
|
+
* TypeScript merge automatiquement avec FastifyRequest.
|
|
23
|
+
*/
|
|
24
|
+
declare module 'fastify' {
|
|
25
|
+
interface FastifyRequest {
|
|
26
|
+
/** Trail parsé depuis l'URL de la requête */
|
|
27
|
+
trail: Trail
|
|
28
|
+
|
|
29
|
+
/** LinkBuilder configuré avec le graphe de l'instance */
|
|
30
|
+
linkBuilder: LinkBuilder
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extrait le contexte utilisateur depuis une requête Fastify.
|
|
36
|
+
* Extensible par le dev via les options du plugin.
|
|
37
|
+
*/
|
|
38
|
+
export type UserContextExtractor = (
|
|
39
|
+
req: FastifyRequest
|
|
40
|
+
) => Promise<Record<string, any>> | Record<string, any>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extracteur par défaut — lit req.user si présent (JWT/session)
|
|
44
|
+
*/
|
|
45
|
+
export const defaultUserExtractor: UserContextExtractor = (req) => {
|
|
46
|
+
const r = req as any
|
|
47
|
+
return r.user ?? r.session?.user ?? {}
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exemple d'intégration Netflix
|
|
3
|
+
* Fichier à titre d'exemple uniquement, non exporté.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'module'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import Fastify from 'fastify'
|
|
10
|
+
import { linklabPlugin } from './index.js'
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url)
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const dataDir = path.join(__dirname, '../examples/netflix/data')
|
|
15
|
+
|
|
16
|
+
const compiledGraph = require('../examples/netflix/compiled-graph.json')
|
|
17
|
+
const movies = require(path.join(dataDir, 'movies.json'))
|
|
18
|
+
const people = require(path.join(dataDir, 'people.json'))
|
|
19
|
+
const credits = require(path.join(dataDir, 'credits.json'))
|
|
20
|
+
const categories = require(path.join(dataDir, 'categories.json'))
|
|
21
|
+
const departments = require(path.join(dataDir, 'departments.json'))
|
|
22
|
+
const jobs = require(path.join(dataDir, 'jobs.json'))
|
|
23
|
+
|
|
24
|
+
const fastify = Fastify({ logger: true })
|
|
25
|
+
|
|
26
|
+
await fastify.register(linklabPlugin, {
|
|
27
|
+
graph: compiledGraph,
|
|
28
|
+
prefix: '/api',
|
|
29
|
+
global: { domain: 'netflix', version: 'v1' },
|
|
30
|
+
|
|
31
|
+
dataLoader: {
|
|
32
|
+
dataset: { movies, people, credits, categories, departments, jobs }
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
extractUser: async (req) => {
|
|
36
|
+
const auth = req.headers.authorization
|
|
37
|
+
if (!auth) return {}
|
|
38
|
+
return { userId: 'u_123', subscription: 'premium', locale: 'fr-FR' }
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
onEngine: (engine, req) => {
|
|
42
|
+
const accessHandler: any = async (ctx: any) => {
|
|
43
|
+
const protected_ = ['movies', 'series', 'episodes']
|
|
44
|
+
if (protected_.includes(ctx.node) && !ctx.trail?.user?.subscription) {
|
|
45
|
+
return { cancelled: true, reason: 'subscription_required' }
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
engine.hooks.on('access.check', accessHandler)
|
|
50
|
+
engine.events.on('traversal.complete', ({ routeUsed, durationMs }) => {
|
|
51
|
+
fastify.log.info({ routeUsed, durationMs }, 'traversal')
|
|
52
|
+
})
|
|
53
|
+
engine.errors.on('route.notfound', ({ from, to }) => {
|
|
54
|
+
fastify.log.warn({ from, to }, 'route not found')
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await fastify.listen({ port: 3000 })
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
1. packages/linklab-http/README.md
|
|
2
|
+
Ce document présente le bridge entre le moteur LinkLab et le monde Web via Fastify.
|
|
3
|
+
|
|
4
|
+
Markdown
|
|
5
|
+
|
|
6
|
+
# @linklab/http
|
|
7
|
+
|
|
8
|
+
Ce package fournit l'intégration HTTP officielle pour LinkLab, permettant d'exposer un graphe sémantique sous forme d'API **HATEOAS** automatisée.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add @linklab/http
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Usage (Fastify Plugin)
|
|
17
|
+
Le plugin linklabPlugin transforme vos définitions de graphes en routes API prêtes à l'emploi.
|
|
18
|
+
|
|
19
|
+
```typeScript
|
|
20
|
+
import Fastify from 'fastify'
|
|
21
|
+
import { linklabPlugin } from '@linklab/http'
|
|
22
|
+
|
|
23
|
+
const fastify = Fastify()
|
|
24
|
+
|
|
25
|
+
await fastify.register(linklabPlugin, {
|
|
26
|
+
graph: semanticGraph, // Le graphe logique (noeuds/arêtes)
|
|
27
|
+
compiledGraph: compiledGraph, // Le graphe compilé pour la navigation
|
|
28
|
+
prefix: '/api', // Préfixe de l'API
|
|
29
|
+
dataLoader: {
|
|
30
|
+
dataset: { movies, people, credits, ... }
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
fastify.listen({ port: 3000 })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 1. Fonctionnalités
|
|
38
|
+
|
|
39
|
+
- HATEOAS Automatique : Injection de l'objet \_links dans chaque réponse.
|
|
40
|
+
- Navigation Récursive : Support des chemins complexes (ex: /movies/278/credits/people).
|
|
41
|
+
- Découvrabilité : Navigation fluide de parent à enfant (self, up, et relations nommées).
|
|
42
|
+
- Instrumentation : Compatible avec @linklab/telemetry pour le monitoring de la tension API.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 2. docs/architecture/hateoas.md
|
|
47
|
+
|
|
48
|
+
Ce document détaille la logique de navigation par liens hypermédias.
|
|
49
|
+
|
|
50
|
+
````markdown
|
|
51
|
+
# Architecture HATEOAS dans LinkLab
|
|
52
|
+
|
|
53
|
+
L'implémentation HATEOAS (_Hypermedia as the Engine of Application State_) de LinkLab repose sur la structure du graphe sémantique pour générer dynamiquement des liens de navigation.
|
|
54
|
+
|
|
55
|
+
## Concept de Navigation
|
|
56
|
+
|
|
57
|
+
Contrairement aux API REST classiques où les URLs sont codées en dur, LinkLab utilise les arêtes (edges) du graphe pour déterminer les transitions d'état possibles.
|
|
58
|
+
|
|
59
|
+
### Structure d'une Réponse
|
|
60
|
+
|
|
61
|
+
Chaque entité renvoyée par le `linklabPlugin` est enrichie d'un bloc `_links` :
|
|
62
|
+
|
|
63
|
+
- **self** : L'URL unique de la ressource actuelle.
|
|
64
|
+
- **up** : Le lien vers la collection parente (nœud précédent dans la hiérarchie).
|
|
65
|
+
- **Relations** : Liens vers les nœuds adjacents définis dans le graphe (ex: `credits`, `categories`, `people`).
|
|
66
|
+
|
|
67
|
+
### Exemple de Payload
|
|
68
|
+
|
|
69
|
+
Pour une requête sur `/api/movies/278` :
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"id": 278,
|
|
74
|
+
"title": "The Shawshank Redemption",
|
|
75
|
+
"_links": {
|
|
76
|
+
"self": { "href": "/api/movies/278", "method": "GET" },
|
|
77
|
+
"up": { "href": "/api/movies", "title": "Collection movies" },
|
|
78
|
+
"credits": { "href": "/api/movies/278/credits", "title": "LIST_OF_CREDITS" },
|
|
79
|
+
"people": { "href": "/api/movies/278/people", "title": "actor" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
````
|
|
84
|
+
|
|
85
|
+
## 3. Résolution des Chemins
|
|
86
|
+
|
|
87
|
+
Le NavigationEngine de LinkLab est sollicité à chaque requête pour valider que le chemin demandé existe dans le graphe compilé avant de déléguer l'extraction des données au QueryEngine.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http — Exports du module HTTP LinkLab
|
|
3
|
+
*
|
|
4
|
+
* Point d'entrée unique pour le plugin Fastify et
|
|
5
|
+
* les utilitaires HTTP associés.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { linklabPlugin, LinkBuilder, TrailParser } from '@linklab/http'
|
|
10
|
+
*
|
|
11
|
+
* await fastify.register(linklabPlugin, {
|
|
12
|
+
* graph: compiledGraph,
|
|
13
|
+
* prefix: '/api',
|
|
14
|
+
* onEngine: (engine, req) => {
|
|
15
|
+
* engine.hooks.on('access.check', async (ctx) => {
|
|
16
|
+
* if (!ctx.trail.user.userId) {
|
|
17
|
+
* return { cancelled: true, reason: 'unauthenticated' }
|
|
18
|
+
* }
|
|
19
|
+
* })
|
|
20
|
+
* }
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export { linklabPlugin } from './plugin.js'
|
|
26
|
+
export { LinkBuilder } from './LinkBuilder.js'
|
|
27
|
+
export { DataLoader } from '../runtime/DataLoader.js'
|
|
28
|
+
|
|
29
|
+
export type { LinklabPluginOptions } from './plugin.js'
|
|
30
|
+
export type { TrailResponse, ResponseMeta } from './plugin.js'
|
|
31
|
+
export type { HateoasLink, HateoasLinks, LinkBuilderOptions } from './LinkBuilder.js'
|
|
32
|
+
export type { UserContextExtractor } from './TrailRequest.js'
|
|
33
|
+
export type { DataLoaderOptions } from '../runtime/DataLoader.js'
|