@likec4/leanix-bridge 1.53.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/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/index.d.mts +559 -0
- package/dist/index.mjs +1110 -0
- package/package.json +62 -0
- package/src/adr-generation.ts +105 -0
- package/src/contracts.ts +96 -0
- package/src/drawio-leanix-roundtrip.ts +51 -0
- package/src/drift-report.ts +61 -0
- package/src/fixture-model.ts +52 -0
- package/src/governance-checks.ts +103 -0
- package/src/impact-report.ts +38 -0
- package/src/index.ts +109 -0
- package/src/leanix-api-client.ts +138 -0
- package/src/leanix-graphql-operations.ts +160 -0
- package/src/leanix-inventory-snapshot.ts +263 -0
- package/src/mapping.ts +88 -0
- package/src/model-input.ts +36 -0
- package/src/reconcile.ts +227 -0
- package/src/report.ts +73 -0
- package/src/sync-to-leanix.ts +352 -0
- package/src/to-bridge-manifest.ts +85 -0
- package/src/to-leanix-inventory-dry-run.ts +105 -0
- package/src/validate.ts +106 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level LeanIX GraphQL API client.
|
|
3
|
+
* Handles authentication (Bearer token), request throttling, and errors.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Configuration for LeanixApiClient (apiToken, baseUrl, requestDelayMs). */
|
|
7
|
+
export interface LeanixApiClientConfig {
|
|
8
|
+
/** LeanIX API token (required for auth). */
|
|
9
|
+
apiToken: string
|
|
10
|
+
/**
|
|
11
|
+
* Base URL for the LeanIX API (e.g. https://app.leanix.net or https://<workspace>.leanix.net).
|
|
12
|
+
* Default: https://app.leanix.net
|
|
13
|
+
*/
|
|
14
|
+
baseUrl?: string
|
|
15
|
+
/**
|
|
16
|
+
* Delay in ms between consecutive GraphQL requests (rate limiting).
|
|
17
|
+
* Default: 200
|
|
18
|
+
*/
|
|
19
|
+
requestDelayMs?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_BASE_URL = 'https://app.leanix.net'
|
|
23
|
+
const DEFAULT_DELAY_MS = 200
|
|
24
|
+
|
|
25
|
+
/** Result of a GraphQL request (data or errors). */
|
|
26
|
+
export interface GraphQLResponse<T = unknown> {
|
|
27
|
+
data?: T
|
|
28
|
+
errors?: Array<{ message: string; path?: string[]; extensions?: Record<string, unknown> }>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Thrown when the LeanIX API returns errors or non-OK HTTP. */
|
|
32
|
+
export class LeanixApiError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
public readonly statusCode?: number,
|
|
36
|
+
public readonly graphqlErrors?: GraphQLResponse['errors'],
|
|
37
|
+
) {
|
|
38
|
+
super(message)
|
|
39
|
+
this.name = 'LeanixApiError'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sleep(ms: number): Promise<void> {
|
|
44
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* LeanIX GraphQL API client with Bearer auth and optional rate limiting.
|
|
49
|
+
* Throttle state is per instance so multiple clients do not share delay.
|
|
50
|
+
*/
|
|
51
|
+
export class LeanixApiClient {
|
|
52
|
+
private readonly baseUrl: string
|
|
53
|
+
private readonly apiToken: string
|
|
54
|
+
private readonly requestDelayMs: number
|
|
55
|
+
/** Last request timestamp for throttling (per instance). */
|
|
56
|
+
private lastRequestTime = 0
|
|
57
|
+
/** Serializes rate-limit and request so only one caller runs at a time. */
|
|
58
|
+
private rateLimitLock: Promise<void> = Promise.resolve()
|
|
59
|
+
|
|
60
|
+
constructor(config: LeanixApiClientConfig) {
|
|
61
|
+
this.apiToken = config.apiToken
|
|
62
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '')
|
|
63
|
+
this.requestDelayMs = config.requestDelayMs ?? DEFAULT_DELAY_MS
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Execute a GraphQL operation (query or mutation).
|
|
68
|
+
* Throttles requests by requestDelayMs. Throws LeanixApiError on HTTP or GraphQL errors.
|
|
69
|
+
*/
|
|
70
|
+
async graphql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
|
71
|
+
const run = async (): Promise<T> => {
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
const elapsed = now - this.lastRequestTime
|
|
74
|
+
if (elapsed < this.requestDelayMs) {
|
|
75
|
+
await sleep(this.requestDelayMs - elapsed)
|
|
76
|
+
}
|
|
77
|
+
this.lastRequestTime = Date.now()
|
|
78
|
+
|
|
79
|
+
const url = `${this.baseUrl}/services/pathfinder/v1/graphql`
|
|
80
|
+
let res: Response
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(url, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ query, variables }),
|
|
89
|
+
})
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
92
|
+
throw new LeanixApiError(`GraphQL request failed: ${url} POST - ${msg}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let body: GraphQLResponse<T>
|
|
96
|
+
try {
|
|
97
|
+
body = (await res.json()) as GraphQLResponse<T>
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
100
|
+
throw new LeanixApiError(
|
|
101
|
+
`Invalid JSON response: ${url} ${res.status} ${res.statusText} - ${msg}`,
|
|
102
|
+
res.status,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw new LeanixApiError(
|
|
108
|
+
body.errors?.[0]?.message ?? `HTTP ${res.status} ${res.statusText}`,
|
|
109
|
+
res.status,
|
|
110
|
+
body.errors,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (body.errors && body.errors.length > 0) {
|
|
115
|
+
const msg = body.errors.map(e => e.message).join('; ')
|
|
116
|
+
throw new LeanixApiError(msg, res.status, body.errors)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (body.data === undefined) {
|
|
120
|
+
throw new LeanixApiError('GraphQL response had no data and no errors')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return body.data as T
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const prev = this.rateLimitLock
|
|
127
|
+
let resolve: () => void
|
|
128
|
+
this.rateLimitLock = new Promise<void>(r => {
|
|
129
|
+
resolve = r
|
|
130
|
+
})
|
|
131
|
+
await prev
|
|
132
|
+
try {
|
|
133
|
+
return await run()
|
|
134
|
+
} finally {
|
|
135
|
+
resolve!()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LeanIX GraphQL operations used by sync (find, create, patch fact sheets; create relations).
|
|
3
|
+
* Extracted for SRP; sync-to-leanix orchestrates, this module performs API calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LeanixApiClient } from './leanix-api-client'
|
|
7
|
+
import type { LeanixFactSheetDryRun } from './to-leanix-inventory-dry-run'
|
|
8
|
+
|
|
9
|
+
/** Search fact sheets by name and type (for idempotency). Returns null when not found; throws on API/network error. */
|
|
10
|
+
export async function findFactSheetByNameAndType(
|
|
11
|
+
client: LeanixApiClient,
|
|
12
|
+
name: string,
|
|
13
|
+
type: string,
|
|
14
|
+
): Promise<string | null> {
|
|
15
|
+
type AllFactSheetsResult = {
|
|
16
|
+
allFactSheets?: {
|
|
17
|
+
edges?: Array<{
|
|
18
|
+
node?: { id?: string; name?: string; type?: string }
|
|
19
|
+
}>
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const query = `
|
|
23
|
+
query FindFactSheet($name: String!, $type: String!) {
|
|
24
|
+
allFactSheets(filter: { name: $name, factSheetType: $type }) {
|
|
25
|
+
edges { node { id name type } }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`
|
|
29
|
+
const data = await client.graphql<AllFactSheetsResult>(query, { name, type })
|
|
30
|
+
const edges = data.allFactSheets?.edges ?? []
|
|
31
|
+
if (edges.length > 1) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Multiple fact sheets found for name="${name}" type="${type}". Ensure unique name+type in LeanIX or use likec4Id attribute for lookup.`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
const first = edges[0]?.node
|
|
37
|
+
return first?.id ?? null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Search fact sheets by custom attribute (e.g. likec4Id) for idempotent lookup.
|
|
42
|
+
* Returns null when not found; throws on API/network error.
|
|
43
|
+
*/
|
|
44
|
+
export async function findFactSheetByLikec4IdAttribute(
|
|
45
|
+
client: LeanixApiClient,
|
|
46
|
+
attributeKey: string,
|
|
47
|
+
likec4Id: string,
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
type AllFactSheetsResult = {
|
|
50
|
+
allFactSheets?: {
|
|
51
|
+
edges?: Array<{
|
|
52
|
+
node?: { id?: string }
|
|
53
|
+
}>
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
type FilterInput = {
|
|
57
|
+
facetFilters?: Array<{ facetKey: string; operator: string; keys: string[] }>
|
|
58
|
+
}
|
|
59
|
+
const query = `
|
|
60
|
+
query FindFactSheetByAttribute($filter: FilterInput!) {
|
|
61
|
+
allFactSheets(filter: $filter) {
|
|
62
|
+
edges { node { id } }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
`
|
|
66
|
+
const filter: FilterInput = {
|
|
67
|
+
facetFilters: [{ facetKey: attributeKey, operator: 'OR', keys: [likec4Id] }],
|
|
68
|
+
}
|
|
69
|
+
const data = await client.graphql<AllFactSheetsResult>(query, { filter })
|
|
70
|
+
const edges = data.allFactSheets?.edges ?? []
|
|
71
|
+
if (edges.length > 1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Multiple fact sheets found for attribute ${attributeKey}=${likec4Id}. Ensure unique likec4Id in LeanIX.`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
const first = edges[0]?.node
|
|
77
|
+
return first?.id ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Patch an existing fact sheet to set a custom attribute (e.g. likec4Id). Throws on API error. */
|
|
81
|
+
export async function patchFactSheetAttribute(
|
|
82
|
+
client: LeanixApiClient,
|
|
83
|
+
factSheetId: string,
|
|
84
|
+
attributeKey: string,
|
|
85
|
+
value: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
type UpdateResult = { updateFactSheet?: { factSheet?: { id: string } } }
|
|
88
|
+
const patches = [{ op: 'replace', path: `/factSheetAttributes/${attributeKey}`, value }]
|
|
89
|
+
const mutation = `
|
|
90
|
+
mutation UpdateFactSheet($id: ID!, $patches: [Patch]) {
|
|
91
|
+
updateFactSheet(id: $id, patches: $patches) {
|
|
92
|
+
factSheet { id }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
`
|
|
96
|
+
const data = await client.graphql<UpdateResult>(mutation, { id: factSheetId, patches })
|
|
97
|
+
if (!data.updateFactSheet?.factSheet?.id) {
|
|
98
|
+
throw new Error(`updateFactSheet did not return fact sheet (id=${factSheetId}, attribute=${attributeKey})`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Create fact sheet (name, type, optional description and likec4Id attribute). Returns new id; throws on API error. */
|
|
103
|
+
export async function createFactSheet(
|
|
104
|
+
client: LeanixApiClient,
|
|
105
|
+
fs: LeanixFactSheetDryRun,
|
|
106
|
+
likec4IdAttribute: string | undefined,
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
type CreateResult = { createFactSheet?: { factSheet?: { id: string } } }
|
|
109
|
+
const patches: Array<{ op: string; path: string; value: string }> = []
|
|
110
|
+
if (fs.description) {
|
|
111
|
+
patches.push({ op: 'replace', path: '/description', value: fs.description })
|
|
112
|
+
}
|
|
113
|
+
if (likec4IdAttribute && fs.likec4Id) {
|
|
114
|
+
patches.push({ op: 'replace', path: `/factSheetAttributes/${likec4IdAttribute}`, value: fs.likec4Id })
|
|
115
|
+
}
|
|
116
|
+
const mutation = `
|
|
117
|
+
mutation CreateFactSheet($input: CreateFactSheetInput!, $patches: [Patch]) {
|
|
118
|
+
createFactSheet(input: $input, patches: $patches) {
|
|
119
|
+
factSheet { id name type rev }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
const input = { name: fs.name, type: fs.type }
|
|
124
|
+
const variables = { input, patches }
|
|
125
|
+
const data = await client.graphql<CreateResult>(mutation, variables)
|
|
126
|
+
const id = data.createFactSheet?.factSheet?.id
|
|
127
|
+
if (!id) throw new Error(`createFactSheet did not return id for ${String(fs.name)}`)
|
|
128
|
+
return id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Create a relation between two fact sheets. Returns relation id; throws when mutation returns no id. */
|
|
132
|
+
export async function createRelation(
|
|
133
|
+
client: LeanixApiClient,
|
|
134
|
+
sourceFactSheetId: string,
|
|
135
|
+
targetFactSheetId: string,
|
|
136
|
+
relationType: string,
|
|
137
|
+
_title?: string,
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
type CreateResult = { createRelation?: { relation?: { id: string } } }
|
|
140
|
+
const mutation = `
|
|
141
|
+
mutation CreateRelation($source: ID!, $target: ID!, $type: String!) {
|
|
142
|
+
createRelation(source: $source, target: $target, type: $type) {
|
|
143
|
+
relation { id }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
`
|
|
147
|
+
const data = await client.graphql<CreateResult>(mutation, {
|
|
148
|
+
source: sourceFactSheetId,
|
|
149
|
+
target: targetFactSheetId,
|
|
150
|
+
type: relationType,
|
|
151
|
+
})
|
|
152
|
+
const id = data.createRelation?.relation?.id
|
|
153
|
+
if (!id) {
|
|
154
|
+
const payload = JSON.stringify(data, null, 2)
|
|
155
|
+
throw new Error(
|
|
156
|
+
`createRelation did not return relation id (sourceFactSheetId=${sourceFactSheetId}, targetFactSheetId=${targetFactSheetId}, relationType=${relationType}). Response: ${payload}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
return id
|
|
160
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound LeanIX: read-only snapshot of LeanIX inventory (fact sheets + relations).
|
|
3
|
+
* Used for reconciliation with the LikeC4 manifest; no DSL generation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LeanixApiClient } from './leanix-api-client'
|
|
7
|
+
|
|
8
|
+
/** Single fact sheet as returned from LeanIX API (read-only snapshot). */
|
|
9
|
+
export interface LeanixFactSheetSnapshotItem {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
type: string
|
|
13
|
+
/** Set when fetched with likec4IdAttribute and the fact sheet has that custom attribute. */
|
|
14
|
+
likec4Id?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Single relation as returned from LeanIX API (read-only snapshot). */
|
|
18
|
+
export interface LeanixRelationSnapshotItem {
|
|
19
|
+
id?: string
|
|
20
|
+
sourceFactSheetId: string
|
|
21
|
+
targetFactSheetId: string
|
|
22
|
+
type: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read-only snapshot of LeanIX inventory for reconciliation. */
|
|
26
|
+
export interface LeanixInventorySnapshot {
|
|
27
|
+
generatedAt: string
|
|
28
|
+
/** Workspace or project identifier if available from API. */
|
|
29
|
+
workspaceId?: string
|
|
30
|
+
factSheets: LeanixFactSheetSnapshotItem[]
|
|
31
|
+
relations: LeanixRelationSnapshotItem[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Options for fetchLeanixInventorySnapshot (likec4Id attribute, maxFactSheets, generatedAt). */
|
|
35
|
+
export interface FetchLeanixInventorySnapshotOptions {
|
|
36
|
+
/** Custom attribute key to read likec4Id from fact sheets (e.g. "likec4Id"). When set, snapshot items get likec4Id when present. */
|
|
37
|
+
likec4IdAttribute?: string
|
|
38
|
+
/** Max fact sheets to fetch (pagination). Default 1000. */
|
|
39
|
+
maxFactSheets?: number
|
|
40
|
+
/** ISO timestamp for snapshot. Default: new Date().toISOString() */
|
|
41
|
+
generatedAt?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_PAGE_SIZE = 100
|
|
45
|
+
const DEFAULT_MAX_FACT_SHEETS = 1000
|
|
46
|
+
const MAX_GRAPHQL_RETRIES = 3
|
|
47
|
+
|
|
48
|
+
async function withGraphQLRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
49
|
+
let lastErr: unknown
|
|
50
|
+
for (let attempt = 0; attempt < MAX_GRAPHQL_RETRIES; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
return await fn()
|
|
53
|
+
} catch (err) {
|
|
54
|
+
lastErr = err
|
|
55
|
+
if (attempt < MAX_GRAPHQL_RETRIES - 1) {
|
|
56
|
+
await new Promise(r => setTimeout(r, 500 * (attempt + 1)))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw lastErr
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fetches a read-only snapshot of the LeanIX inventory (fact sheets, then relations).
|
|
65
|
+
* Uses cursor-based pagination. Does not modify LeanIX.
|
|
66
|
+
*/
|
|
67
|
+
export async function fetchLeanixInventorySnapshot(
|
|
68
|
+
client: LeanixApiClient,
|
|
69
|
+
options: FetchLeanixInventorySnapshotOptions = {},
|
|
70
|
+
): Promise<LeanixInventorySnapshot> {
|
|
71
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString()
|
|
72
|
+
const maxFactSheets = options.maxFactSheets ?? DEFAULT_MAX_FACT_SHEETS
|
|
73
|
+
if (!Number.isInteger(maxFactSheets) || maxFactSheets < 0) {
|
|
74
|
+
throw new Error('maxFactSheets must be a non-negative integer')
|
|
75
|
+
}
|
|
76
|
+
const likec4IdAttribute = options.likec4IdAttribute
|
|
77
|
+
|
|
78
|
+
const factSheets = await fetchAllFactSheets(client, {
|
|
79
|
+
...(likec4IdAttribute != null ? { likec4IdAttribute } : {}),
|
|
80
|
+
maxFactSheets,
|
|
81
|
+
})
|
|
82
|
+
const relations = await fetchAllRelations(client, factSheets.map(f => f.id))
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
generatedAt,
|
|
86
|
+
factSheets,
|
|
87
|
+
relations,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type FactSheetNode = {
|
|
92
|
+
id?: string
|
|
93
|
+
name?: string
|
|
94
|
+
type?: string
|
|
95
|
+
factSheetAttributes?: Array<{ key?: string; value?: string }>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Maps a GraphQL node to LeanixFactSheetSnapshotItem; returns null when node has no id. */
|
|
99
|
+
function mapNodeToFactSheetItem(
|
|
100
|
+
node: FactSheetNode | undefined,
|
|
101
|
+
likec4IdAttribute: string | undefined,
|
|
102
|
+
): LeanixFactSheetSnapshotItem | null {
|
|
103
|
+
if (!node?.id) return null
|
|
104
|
+
const likec4Id = likec4IdAttribute != null && Array.isArray(node.factSheetAttributes)
|
|
105
|
+
? node.factSheetAttributes.find((a: { key?: string; value?: string }) => a.key === likec4IdAttribute)?.value
|
|
106
|
+
: undefined
|
|
107
|
+
return {
|
|
108
|
+
id: node.id,
|
|
109
|
+
name: node.name ?? '',
|
|
110
|
+
type: node.type ?? '',
|
|
111
|
+
...(likec4Id ? { likec4Id } : {}),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fetchAllFactSheets(
|
|
116
|
+
client: LeanixApiClient,
|
|
117
|
+
opts: { likec4IdAttribute?: string; maxFactSheets: number },
|
|
118
|
+
): Promise<LeanixInventorySnapshot['factSheets']> {
|
|
119
|
+
const likec4IdAttribute = opts.likec4IdAttribute
|
|
120
|
+
const pageSize = Math.min(DEFAULT_PAGE_SIZE, opts.maxFactSheets)
|
|
121
|
+
const result: LeanixInventorySnapshot['factSheets'] = []
|
|
122
|
+
let after: string | null = null
|
|
123
|
+
let hasNextPage = true
|
|
124
|
+
|
|
125
|
+
const attributeSelection = likec4IdAttribute != null
|
|
126
|
+
? `factSheetAttributes { key value }`
|
|
127
|
+
: ''
|
|
128
|
+
|
|
129
|
+
const query = `
|
|
130
|
+
query AllFactSheets($first: Int!, $after: String, $filter: FilterInput) {
|
|
131
|
+
allFactSheets(first: $first, after: $after, filter: $filter) {
|
|
132
|
+
edges {
|
|
133
|
+
node {
|
|
134
|
+
id
|
|
135
|
+
name
|
|
136
|
+
type
|
|
137
|
+
${attributeSelection}
|
|
138
|
+
}
|
|
139
|
+
cursor
|
|
140
|
+
}
|
|
141
|
+
pageInfo {
|
|
142
|
+
hasNextPage
|
|
143
|
+
endCursor
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
`
|
|
148
|
+
|
|
149
|
+
type PageRes = {
|
|
150
|
+
allFactSheets?: {
|
|
151
|
+
edges?: Array<{ node?: FactSheetNode; cursor?: string }>
|
|
152
|
+
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fetchPage = async (cursor: string | null): Promise<PageRes> =>
|
|
157
|
+
withGraphQLRetry(() =>
|
|
158
|
+
client.graphql<PageRes>(query, {
|
|
159
|
+
first: pageSize,
|
|
160
|
+
after: cursor,
|
|
161
|
+
filter: {},
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
while (hasNextPage && result.length < opts.maxFactSheets) {
|
|
166
|
+
const data: PageRes = await fetchPage(after)
|
|
167
|
+
const edges = data.allFactSheets?.edges ?? []
|
|
168
|
+
const pageInfo = data.allFactSheets?.pageInfo
|
|
169
|
+
|
|
170
|
+
for (const edge of edges) {
|
|
171
|
+
if (result.length >= opts.maxFactSheets) break
|
|
172
|
+
const item = mapNodeToFactSheetItem(edge.node, likec4IdAttribute)
|
|
173
|
+
if (item) result.push(item)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
hasNextPage = pageInfo?.hasNextPage === true && result.length < opts.maxFactSheets
|
|
177
|
+
after = pageInfo?.endCursor ?? null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const RELATIONS_FETCH_CONCURRENCY = 10
|
|
184
|
+
const RELATIONS_PAGE_SIZE = 100
|
|
185
|
+
|
|
186
|
+
async function fetchAllRelations(
|
|
187
|
+
client: LeanixApiClient,
|
|
188
|
+
factSheetIds: string[],
|
|
189
|
+
): Promise<LeanixInventorySnapshot['relations']> {
|
|
190
|
+
if (factSheetIds.length === 0) return []
|
|
191
|
+
|
|
192
|
+
const relations: LeanixRelationSnapshotItem[] = []
|
|
193
|
+
const idSet = new Set(factSheetIds)
|
|
194
|
+
|
|
195
|
+
type RelationsResult = {
|
|
196
|
+
factSheet?: {
|
|
197
|
+
id?: string
|
|
198
|
+
relations?: {
|
|
199
|
+
edges?: Array<{
|
|
200
|
+
node?: {
|
|
201
|
+
id?: string
|
|
202
|
+
type?: string
|
|
203
|
+
targetFactSheet?: { id?: string }
|
|
204
|
+
}
|
|
205
|
+
}>
|
|
206
|
+
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const query = `
|
|
212
|
+
query FactSheetRelations($id: ID!, $first: Int, $after: String) {
|
|
213
|
+
factSheet(id: $id) {
|
|
214
|
+
id
|
|
215
|
+
relations(first: $first, after: $after) {
|
|
216
|
+
edges {
|
|
217
|
+
node {
|
|
218
|
+
id
|
|
219
|
+
type
|
|
220
|
+
targetFactSheet { id }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
pageInfo { hasNextPage endCursor }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
`
|
|
228
|
+
|
|
229
|
+
async function fetchRelationsForFactSheet(sourceId: string): Promise<LeanixRelationSnapshotItem[]> {
|
|
230
|
+
const out: LeanixRelationSnapshotItem[] = []
|
|
231
|
+
let after: string | null = null
|
|
232
|
+
let hasNextPage = true
|
|
233
|
+
while (hasNextPage) {
|
|
234
|
+
const data = await withGraphQLRetry(() =>
|
|
235
|
+
client.graphql<RelationsResult>(query, { id: sourceId, first: RELATIONS_PAGE_SIZE, after })
|
|
236
|
+
)
|
|
237
|
+
const edges = data?.factSheet?.relations?.edges ?? []
|
|
238
|
+
const pageInfo = data?.factSheet?.relations?.pageInfo
|
|
239
|
+
for (const edge of edges) {
|
|
240
|
+
const node = edge.node
|
|
241
|
+
const targetId = node?.targetFactSheet?.id
|
|
242
|
+
if (!targetId || !idSet.has(targetId)) continue
|
|
243
|
+
out.push({
|
|
244
|
+
...(node?.id ? { id: node.id } : {}),
|
|
245
|
+
sourceFactSheetId: sourceId,
|
|
246
|
+
targetFactSheetId: targetId,
|
|
247
|
+
type: node?.type ?? 'RELATES_TO',
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
hasNextPage = pageInfo?.hasNextPage === true
|
|
251
|
+
after = pageInfo?.endCursor ?? null
|
|
252
|
+
}
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < factSheetIds.length; i += RELATIONS_FETCH_CONCURRENCY) {
|
|
257
|
+
const batch = factSheetIds.slice(i, i + RELATIONS_FETCH_CONCURRENCY)
|
|
258
|
+
const results = await Promise.all(batch.map(sourceId => fetchRelationsForFactSheet(sourceId)))
|
|
259
|
+
for (const items of results) relations.push(...items)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return relations
|
|
263
|
+
}
|
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable LeanIX mapping: LikeC4 kinds/relations/tags → LeanIX fact sheet types and fields.
|
|
3
|
+
* No universal taxonomy; safe defaults. Actor kind maps to 'Provider' unless overridden.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Configurable mapping from LikeC4 kinds/relations/tags to LeanIX fact sheet and relation types. */
|
|
7
|
+
export interface LeanixMappingConfig {
|
|
8
|
+
/** LikeC4 element kind → LeanIX fact sheet type */
|
|
9
|
+
factSheetTypes?: Record<string, string>
|
|
10
|
+
/** LikeC4 relationship kind → LeanIX relation type name */
|
|
11
|
+
relationTypes?: Record<string, string>
|
|
12
|
+
/** LikeC4 tags / metadata keys → LeanIX field names (optional) */
|
|
13
|
+
metadataToFields?: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Fallback when element kind is unknown (G25: named constant). */
|
|
17
|
+
export const FALLBACK_FACT_SHEET_TYPE = 'Application'
|
|
18
|
+
|
|
19
|
+
/** Fallback when relation kind is unknown (G25: named constant). */
|
|
20
|
+
export const FALLBACK_RELATION_TYPE = 'depends on'
|
|
21
|
+
|
|
22
|
+
/** Default mapping: actor → Provider; override factSheetTypes/relationTypes as needed */
|
|
23
|
+
export const DEFAULT_LEANIX_MAPPING: Required<LeanixMappingConfig> = {
|
|
24
|
+
factSheetTypes: {
|
|
25
|
+
system: 'Application',
|
|
26
|
+
container: 'ITComponent',
|
|
27
|
+
component: 'ITComponent',
|
|
28
|
+
actor: 'Provider',
|
|
29
|
+
},
|
|
30
|
+
relationTypes: {
|
|
31
|
+
default: FALLBACK_RELATION_TYPE,
|
|
32
|
+
},
|
|
33
|
+
metadataToFields: {
|
|
34
|
+
title: 'name',
|
|
35
|
+
description: 'description',
|
|
36
|
+
technology: 'technology',
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Merges partial mapping config with DEFAULT_LEANIX_MAPPING; returns a full Required<LeanixMappingConfig>.
|
|
42
|
+
*/
|
|
43
|
+
export function mergeWithDefault(partial?: LeanixMappingConfig | null): Required<LeanixMappingConfig> {
|
|
44
|
+
const base = {
|
|
45
|
+
factSheetTypes: { ...DEFAULT_LEANIX_MAPPING.factSheetTypes },
|
|
46
|
+
relationTypes: { ...DEFAULT_LEANIX_MAPPING.relationTypes },
|
|
47
|
+
metadataToFields: { ...DEFAULT_LEANIX_MAPPING.metadataToFields },
|
|
48
|
+
}
|
|
49
|
+
if (!partial) {
|
|
50
|
+
return base
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
factSheetTypes: { ...base.factSheetTypes, ...partial.factSheetTypes },
|
|
54
|
+
relationTypes: { ...base.relationTypes, ...partial.relationTypes },
|
|
55
|
+
metadataToFields: { ...base.metadataToFields, ...partial.metadataToFields },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns LeanIX fact sheet type for a LikeC4 element kind.
|
|
61
|
+
* Uses mapping.factSheetTypes[kind], then 'default', then FALLBACK_FACT_SHEET_TYPE.
|
|
62
|
+
*/
|
|
63
|
+
export function getFactSheetType(
|
|
64
|
+
likec4Kind: string,
|
|
65
|
+
mapping: Required<LeanixMappingConfig>,
|
|
66
|
+
): string {
|
|
67
|
+
return (
|
|
68
|
+
mapping.factSheetTypes[likec4Kind] ??
|
|
69
|
+
mapping.factSheetTypes['default'] ??
|
|
70
|
+
FALLBACK_FACT_SHEET_TYPE
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns LeanIX relation type for a LikeC4 relationship kind.
|
|
76
|
+
* Uses mapping.relationTypes[kind], then 'default', then FALLBACK_RELATION_TYPE.
|
|
77
|
+
*/
|
|
78
|
+
export function getRelationType(
|
|
79
|
+
likec4Kind: string | null,
|
|
80
|
+
mapping: Required<LeanixMappingConfig>,
|
|
81
|
+
): string {
|
|
82
|
+
const kind = likec4Kind ?? 'default'
|
|
83
|
+
return (
|
|
84
|
+
mapping.relationTypes[kind] ??
|
|
85
|
+
mapping.relationTypes['default'] ??
|
|
86
|
+
FALLBACK_RELATION_TYPE
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal model shape required by the bridge. Satisfied by LikeC4Model (layouted).
|
|
3
|
+
* Keeps the bridge decoupled from concrete model class.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Element shape required by the bridge (id, kind, title, tags, getMetadata). */
|
|
7
|
+
export interface BridgeElementLike {
|
|
8
|
+
id: string
|
|
9
|
+
kind: string
|
|
10
|
+
title: string
|
|
11
|
+
tags: readonly string[]
|
|
12
|
+
technology?: string | null
|
|
13
|
+
getMetadata(): Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Relation shape required by the bridge (id, source, target, kind, title). */
|
|
17
|
+
export interface BridgeRelationLike {
|
|
18
|
+
id: string
|
|
19
|
+
source: { id: string }
|
|
20
|
+
target: { id: string }
|
|
21
|
+
kind: string | null
|
|
22
|
+
title?: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** View shape required by the bridge (id only). */
|
|
26
|
+
export interface BridgeViewLike {
|
|
27
|
+
id: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Model input for toBridgeManifest / toLeanixInventoryDryRun */
|
|
31
|
+
export interface BridgeModelInput {
|
|
32
|
+
projectId: string
|
|
33
|
+
elements(): Iterable<BridgeElementLike>
|
|
34
|
+
relationships(): Iterable<BridgeRelationLike>
|
|
35
|
+
views(): Iterable<BridgeViewLike>
|
|
36
|
+
}
|