kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1const HTTP_METHODS = [
2 "get",
3 "put",
4 "post",
5 "delete",
6 "patch",
7 "head",
8 "options",
9 "trace",
10] as const;
11
12const wordCapitalize = (value: string): string =>
13 value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
14
15const toWords = (value: string): string[] =>
16 value
17 .replace(/[{}]/g, "")
18 .split(/[^a-zA-Z0-9]+/)
19 .map((part) => part.trim().toLowerCase())
20 .filter(Boolean);
21
22const toCamelCase = (parts: string[]): string =>
23 parts
24 .map((part, index) => (index === 0 ? part : wordCapitalize(part)))
25 .join("");
26
27const toTitleCase = (parts: string[]): string =>
28 parts.map((part) => wordCapitalize(part)).join(" ");
29
30const splitCamelCase = (value: string): string[] =>
31 value
32 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
33 .split(/[^a-zA-Z0-9]+/)
34 .map((part) => part.trim())
35 .filter(Boolean);
36
37const summarizeAction = (action: string): string => {
38 if (action === "has") {
39 return "check";
40 }
41 return action;
42};
43
44export const normalizeApiServerUrl = (baseUrl: string): string => {
45 const trimmed = baseUrl.replace(/\/+$/, "");
46 return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;
47};
48
49export const normalizeOrganizationAuthOperations = (
50 authSpec: Record<string, unknown>,
51): Record<string, unknown> => {
52 const normalized = JSON.parse(JSON.stringify(authSpec)) as Record<
53 string,
54 unknown
55 >;
56 const paths = ((normalized as { paths?: unknown }).paths || {}) as Record<
57 string,
58 unknown
59 >;
60 const organizationPaths = Object.fromEntries(
61 Object.entries(paths)
62 .filter(
63 ([path]) =>
64 path.startsWith("/organization") ||
65 path.startsWith("/auth/organization"),
66 )
67 .map(([path, pathItem]) => [
68 path.startsWith("/auth/") ? path : `/auth${path}`,
69 pathItem,
70 ]),
71 ) as Record<string, unknown>;
72
73 for (const [path, pathItem] of Object.entries(organizationPaths)) {
74 if (!pathItem || typeof pathItem !== "object") {
75 continue;
76 }
77
78 const endpointWords = toWords(
79 path.replace(/^\/(?:auth\/)?organization\/?/, ""),
80 );
81 const action = endpointWords[0] || "get";
82 const rest = endpointWords.slice(1);
83 const opIdBaseParts = [action, "organization", ...rest];
84 const summaryVerb = summarizeAction(action);
85 const summaryObjectParts = ["organization", ...rest];
86
87 for (const method of HTTP_METHODS) {
88 const operation = (pathItem as Record<string, unknown>)[method] as
89 | Record<string, unknown>
90 | undefined;
91 if (!operation || typeof operation !== "object") {
92 continue;
93 }
94
95 operation.operationId = toCamelCase(opIdBaseParts);
96 operation.summary = `${wordCapitalize(summaryVerb)} ${toTitleCase(
97 summaryObjectParts,
98 )}`.trim();
99 operation.tags = ["Organization Management"];
100 }
101 }
102
103 const normalizedWithOnlyOrganizationPaths = {
104 ...normalized,
105 paths: organizationPaths,
106 tags: [
107 {
108 name: "Organization Management",
109 },
110 ],
111 } as Record<string, unknown>;
112
113 const refPattern = /^#\/components\/([^/]+)\/([^/]+)$/;
114 const refs = new Set<string>();
115 const scanRefs = (value: unknown) => {
116 if (Array.isArray(value)) {
117 for (const entry of value) {
118 scanRefs(entry);
119 }
120 return;
121 }
122 if (!value || typeof value !== "object") {
123 return;
124 }
125
126 for (const [key, next] of Object.entries(value)) {
127 if (key === "$ref" && typeof next === "string") {
128 refs.add(next);
129 } else {
130 scanRefs(next);
131 }
132 }
133 };
134
135 scanRefs(
136 (
137 normalizedWithOnlyOrganizationPaths as {
138 paths?: unknown;
139 security?: unknown;
140 }
141 ).paths,
142 );
143 scanRefs(
144 (
145 normalizedWithOnlyOrganizationPaths as {
146 paths?: unknown;
147 security?: unknown;
148 }
149 ).security,
150 );
151
152 const sourceComponents = ((normalized as { components?: unknown })
153 .components || {}) as Record<string, unknown>;
154 const prunedComponents: Record<string, unknown> = {};
155
156 if (
157 sourceComponents.securitySchemes &&
158 typeof sourceComponents.securitySchemes === "object"
159 ) {
160 prunedComponents.securitySchemes = sourceComponents.securitySchemes;
161 }
162
163 let changed = true;
164 while (changed) {
165 changed = false;
166 const pendingRefs = [...refs];
167 for (const ref of pendingRefs) {
168 const match = refPattern.exec(ref);
169 if (!match) {
170 continue;
171 }
172 const section = match[1];
173 const name = match[2];
174 if (!section || !name) {
175 continue;
176 }
177 const sourceSection = sourceComponents[section] as
178 | Record<string, unknown>
179 | undefined;
180 if (!sourceSection || !(name in sourceSection)) {
181 continue;
182 }
183
184 if (!(section in prunedComponents)) {
185 prunedComponents[section] = {};
186 }
187 const targetSection = prunedComponents[section] as Record<
188 string,
189 unknown
190 >;
191 if (name in targetSection) {
192 continue;
193 }
194
195 targetSection[name] = sourceSection[name];
196 const before = refs.size;
197 scanRefs(sourceSection[name]);
198 if (refs.size > before) {
199 changed = true;
200 }
201 }
202 }
203
204 if (Object.keys(prunedComponents).length > 0) {
205 normalizedWithOnlyOrganizationPaths.components = prunedComponents;
206 } else {
207 delete normalizedWithOnlyOrganizationPaths.components;
208 }
209
210 return normalizedWithOnlyOrganizationPaths;
211};
212
213export const mergeOpenApiSpecs = (
214 honoSpec: Record<string, unknown>,
215 authSpec: Record<string, unknown>,
216) => {
217 const mergeRecord = (a: unknown, b: unknown): Record<string, unknown> => ({
218 ...((a as Record<string, unknown>) || {}),
219 ...((b as Record<string, unknown>) || {}),
220 });
221
222 const mergeArray = (a: unknown, b: unknown): unknown[] => [
223 ...((a as unknown[]) || []),
224 ...((b as unknown[]) || []),
225 ];
226
227 return {
228 ...honoSpec,
229 openapi:
230 (honoSpec as { openapi?: string }).openapi ||
231 (authSpec as { openapi?: string }).openapi ||
232 "3.0.3",
233 info:
234 (honoSpec as { info?: unknown }).info ||
235 (authSpec as { info?: unknown }).info,
236 servers:
237 (honoSpec as { servers?: unknown[] }).servers ||
238 (authSpec as { servers?: unknown[] }).servers,
239 security:
240 (honoSpec as { security?: unknown[] }).security ||
241 (authSpec as { security?: unknown[] }).security,
242 paths: mergeRecord(
243 (honoSpec as { paths?: unknown }).paths,
244 (authSpec as { paths?: unknown }).paths,
245 ),
246 tags: mergeArray(
247 (honoSpec as { tags?: unknown[] }).tags,
248 (authSpec as { tags?: unknown[] }).tags,
249 ),
250 components: {
251 ...mergeRecord(
252 (honoSpec as { components?: unknown }).components,
253 (authSpec as { components?: unknown }).components,
254 ),
255 schemas: mergeRecord(
256 (honoSpec as { components?: { schemas?: unknown } }).components
257 ?.schemas,
258 (authSpec as { components?: { schemas?: unknown } }).components
259 ?.schemas,
260 ),
261 securitySchemes: mergeRecord(
262 (honoSpec as { components?: { securitySchemes?: unknown } }).components
263 ?.securitySchemes,
264 (authSpec as { components?: { securitySchemes?: unknown } }).components
265 ?.securitySchemes,
266 ),
267 },
268 };
269};
270
271export const dedupeOperationIds = (spec: Record<string, unknown>) => {
272 const paths = ((spec as { paths?: unknown }).paths || {}) as Record<
273 string,
274 unknown
275 >;
276 const seen = new Set<string>();
277
278 for (const [path, pathItem] of Object.entries(paths)) {
279 if (!pathItem || typeof pathItem !== "object") {
280 continue;
281 }
282
283 for (const method of HTTP_METHODS) {
284 const operation = (pathItem as Record<string, unknown>)[method] as
285 | Record<string, unknown>
286 | undefined;
287
288 if (!operation || typeof operation !== "object") {
289 continue;
290 }
291
292 const operationId = operation.operationId;
293 if (typeof operationId !== "string" || operationId.length === 0) {
294 continue;
295 }
296
297 if (!seen.has(operationId)) {
298 seen.add(operationId);
299 continue;
300 }
301
302 const pathSuffix = path
303 .replace(/\//g, "_")
304 .replace(/[{}]/g, "")
305 .replace(/_+/g, "_")
306 .replace(/^_+|_+$/g, "");
307 const nextId = `${operationId}_${method}_${pathSuffix || "root"}`;
308 operation.operationId = nextId;
309 seen.add(nextId);
310 }
311 }
312
313 return spec;
314};
315
316const isPlainObject = (value: unknown): value is Record<string, unknown> =>
317 !!value && typeof value === "object" && !Array.isArray(value);
318
319const setObjectContents = (
320 target: Record<string, unknown>,
321 source: Record<string, unknown>,
322) => {
323 for (const key of Object.keys(target)) {
324 delete target[key];
325 }
326 Object.assign(target, source);
327};
328
329export const normalizeNullableSchemasForOpenApi30 = (
330 spec: Record<string, unknown>,
331) => {
332 const visit = (node: unknown): void => {
333 if (Array.isArray(node)) {
334 for (const item of node) {
335 visit(item);
336 }
337 return;
338 }
339
340 if (!isPlainObject(node)) {
341 return;
342 }
343
344 const typeValue = node.type;
345 if (Array.isArray(typeValue)) {
346 const nullRemoved = typeValue.filter((entry) => entry !== "null");
347 const hadNull = nullRemoved.length !== typeValue.length;
348
349 if (hadNull && nullRemoved.length === 1) {
350 node.type = nullRemoved[0];
351 node.nullable = true;
352 }
353 }
354
355 const anyOfValue = node.anyOf;
356 if (Array.isArray(anyOfValue) && anyOfValue.length >= 2) {
357 const nullSchema = anyOfValue.find(
358 (entry) => isPlainObject(entry) && entry.type === "null",
359 );
360 const nonNullSchemas = anyOfValue.filter(
361 (entry) => !(isPlainObject(entry) && entry.type === "null"),
362 );
363
364 if (
365 nullSchema &&
366 nonNullSchemas.length === 1 &&
367 isPlainObject(nonNullSchemas[0])
368 ) {
369 const { anyOf: _anyOf, ...rest } = node;
370 setObjectContents(node, {
371 ...rest,
372 ...(nonNullSchemas[0] as Record<string, unknown>),
373 nullable: true,
374 });
375 }
376 }
377
378 for (const value of Object.values(node)) {
379 visit(value);
380 }
381 };
382
383 visit(spec);
384 return spec;
385};
386
387export const normalizeEmptyRequiredArrays = (spec: Record<string, unknown>) => {
388 const visit = (node: unknown): void => {
389 if (Array.isArray(node)) {
390 for (const item of node) {
391 visit(item);
392 }
393 return;
394 }
395
396 if (!isPlainObject(node)) {
397 return;
398 }
399
400 if (Array.isArray(node.required) && node.required.length === 0) {
401 delete node.required;
402 }
403
404 for (const value of Object.values(node)) {
405 visit(value);
406 }
407 };
408
409 visit(spec);
410 return spec;
411};
412
413export const ensureOperationSummaries = (spec: Record<string, unknown>) => {
414 const paths = ((spec as { paths?: unknown }).paths || {}) as Record<
415 string,
416 unknown
417 >;
418
419 for (const pathItem of Object.values(paths)) {
420 if (!pathItem || typeof pathItem !== "object") {
421 continue;
422 }
423
424 for (const method of HTTP_METHODS) {
425 const operation = (pathItem as Record<string, unknown>)[method] as
426 | Record<string, unknown>
427 | undefined;
428 if (!operation || typeof operation !== "object") {
429 continue;
430 }
431
432 const summary = operation.summary;
433 if (typeof summary === "string" && summary.trim().length > 0) {
434 continue;
435 }
436
437 const operationId = operation.operationId;
438 if (typeof operationId !== "string" || operationId.trim().length === 0) {
439 continue;
440 }
441
442 const words = splitCamelCase(operationId);
443 if (words.length === 0) {
444 continue;
445 }
446
447 operation.summary = words.map((word) => wordCapitalize(word)).join(" ");
448 }
449 }
450
451 return spec;
452};