@contractkit/plugin-bruno 1.1.0 → 1.3.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/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { resolve, basename, dirname } from 'node:path';
2
2
  import { existsSync, readFileSync, rmSync, readdirSync, rmdirSync } from 'node:fs';
3
3
  import { generateOpenCollection, MANIFEST_FILENAME, parseManifest } from './codegen-bruno.js';
4
4
  import type { BrunoSecurityScheme } from './codegen-bruno.js';
5
- import type { ContractKitPlugin } from '@contractkit/core';
5
+ import type { ContractKitPlugin, PluginValue } from '@contractkit/core';
6
6
 
7
7
  /** Configuration accepted by the Bruno plugin, both via `contractkit.config.json` and `createBrunoPlugin`. */
8
8
  export interface BrunoPluginConfig {
@@ -38,9 +38,37 @@ export interface BrunoPluginOptions extends BrunoPluginConfig {
38
38
 
39
39
  // ─── Default export: loaded via plugins array, reads config from ctx.options ─
40
40
 
41
+ /**
42
+ * Validates a `plugins.bruno` extension entry on an operation. The expected shape is
43
+ * `{ template?: string }`, where `template` is a YAML fragment to deep-merge into the
44
+ * generated request file (typically a `file://...` URL whose contents have already
45
+ * been loaded by the CLI resolver).
46
+ */
47
+ export function validateBrunoExtension(value: PluginValue): { errors?: string[] } | void {
48
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
49
+ return { errors: [`expected an object, got ${describe(value)}`] };
50
+ }
51
+ const errors: string[] = [];
52
+ for (const [key, val] of Object.entries(value)) {
53
+ if (key === 'template') {
54
+ if (typeof val !== 'string') errors.push(`'template' must be a string, got ${describe(val)}`);
55
+ } else {
56
+ errors.push(`unknown field '${key}' (allowed: template)`);
57
+ }
58
+ }
59
+ return errors.length ? { errors } : undefined;
60
+ }
61
+
62
+ function describe(value: PluginValue): string {
63
+ if (value === null) return 'null';
64
+ if (Array.isArray(value)) return 'array';
65
+ return typeof value;
66
+ }
67
+
41
68
  const plugin: ContractKitPlugin = {
42
69
  name: 'bruno',
43
70
  cacheKey: 'bruno',
71
+ validateExtension: validateBrunoExtension,
44
72
  async generateTargets({ opRoots, contractRoots }, ctx) {
45
73
  const { auth, ...config } = ctx.options as BrunoPluginOptions;
46
74
  const base = config.baseDir ? resolve(ctx.rootDir, config.baseDir) : ctx.rootDir;
@@ -85,6 +113,7 @@ export function createBrunoPlugin(
85
113
  return {
86
114
  name: 'bruno',
87
115
  cacheKey: `bruno:${JSON.stringify(config)}`,
116
+ validateExtension: validateBrunoExtension,
88
117
  async generateTargets({ opRoots, contractRoots }, ctx) {
89
118
  const base = config.baseDir ? resolve(rootDir, config.baseDir) : rootDir;
90
119
  const outDir = resolve(base, config.output ?? 'bruno-collection');
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateOpenCollection, sanitizePath, MANIFEST_FILENAME, parseManifest, mergePluginFile } from '../src/codegen-bruno.js';
3
+ import { validateBrunoExtension } from '../src/index.js';
3
4
  import {
4
5
  opRoot,
5
6
  opRoute,
@@ -671,6 +672,48 @@ describe('generateOpenCollection', () => {
671
672
  expect(env!.content).toContain('- name: token');
672
673
  });
673
674
 
675
+ describe('alphabetical ordering', () => {
676
+ function seqOf(content: string): number | undefined {
677
+ const match = content.match(/seq:\s*(\d+)/);
678
+ return match ? parseInt(match[1]!, 10) : undefined;
679
+ }
680
+
681
+ it('orders top-level folders alphabetically by area', () => {
682
+ const usersRoot = opRoot([opRoute('/users', [opOperation('get')])], 'users.op', { area: 'users' });
683
+ const authRoot = opRoot([opRoute('/auth', [opOperation('post')])], 'auth.op', { area: 'auth' });
684
+ const paymentsRoot = opRoot([opRoute('/payments', [opOperation('get')])], 'payments.op', { area: 'payments' });
685
+ // Pass in a non-alphabetical input order to prove the sort is doing the work.
686
+ const files = generateOpenCollection([usersRoot, authRoot, paymentsRoot], { collectionName: 'API' });
687
+
688
+ const auth = files.find(f => f.relativePath === 'auth/folder.yml');
689
+ const payments = files.find(f => f.relativePath === 'payments/folder.yml');
690
+ const users = files.find(f => f.relativePath === 'users/folder.yml');
691
+ expect(seqOf(auth!.content)).toBe(1);
692
+ expect(seqOf(payments!.content)).toBe(2);
693
+ expect(seqOf(users!.content)).toBe(3);
694
+ });
695
+
696
+ it('orders requests within a folder alphabetically by request name', () => {
697
+ const root = opRoot(
698
+ [
699
+ opRoute('/zebras', [opOperation('get', { name: 'Zebra list' })]),
700
+ opRoute('/aardvarks', [opOperation('get', { name: 'Aardvark list' })]),
701
+ opRoute('/mammals', [opOperation('get', { name: 'Mammal list' })]),
702
+ ],
703
+ 'zoo.op',
704
+ { area: 'zoo' },
705
+ );
706
+ const files = generateOpenCollection([root], { collectionName: 'API' });
707
+
708
+ const aardvark = files.find(f => f.relativePath === 'zoo/aardvark-list.yml');
709
+ const mammal = files.find(f => f.relativePath === 'zoo/mammal-list.yml');
710
+ const zebra = files.find(f => f.relativePath === 'zoo/zebra-list.yml');
711
+ expect(seqOf(aardvark!.content)).toBe(1);
712
+ expect(seqOf(mammal!.content)).toBe(2);
713
+ expect(seqOf(zebra!.content)).toBe(3);
714
+ });
715
+ });
716
+
674
717
  describe('environments config', () => {
675
718
  const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
676
719
 
@@ -1074,7 +1117,7 @@ describe('plugin file merges', () => {
1074
1117
  opRoute('/users', [
1075
1118
  opOperation('get', {
1076
1119
  responses: [opResponse(200, 'User')],
1077
- pluginFiles: { bruno: 'runtime:\n script:\n req: |\n console.log("pre");\n' },
1120
+ pluginExtensions: { bruno: { template: 'runtime:\n script:\n req: |\n console.log("pre");\n' } },
1078
1121
  }),
1079
1122
  ]),
1080
1123
  ]);
@@ -1092,17 +1135,19 @@ describe('plugin file merges', () => {
1092
1135
  opRoute('/users', [
1093
1136
  opOperation('get', {
1094
1137
  responses: [opResponse(200)],
1095
- pluginFiles: {
1096
- bruno: [
1097
- 'runtime:',
1098
- ' assertions:',
1099
- ' - expression: res.status',
1100
- ' operator: eq',
1101
- ' value: "200"',
1102
- ' - expression: res.headers["x-request-id"]',
1103
- ' operator: isDefined',
1104
- ' value: ""',
1105
- ].join('\n'),
1138
+ pluginExtensions: {
1139
+ bruno: {
1140
+ template: [
1141
+ 'runtime:',
1142
+ ' assertions:',
1143
+ ' - expression: res.status',
1144
+ ' operator: eq',
1145
+ ' value: "200"',
1146
+ ' - expression: res.headers["x-request-id"]',
1147
+ ' operator: isDefined',
1148
+ ' value: ""',
1149
+ ].join('\n'),
1150
+ },
1106
1151
  },
1107
1152
  }),
1108
1153
  ]),
@@ -1119,7 +1164,7 @@ describe('plugin file merges', () => {
1119
1164
  opRoute('/users', [
1120
1165
  opOperation('get', {
1121
1166
  responses: [opResponse(200)],
1122
- pluginFiles: { bruno: 'runtime:\n script:\n req: |\n // pre\n' },
1167
+ pluginExtensions: { bruno: { template: 'runtime:\n script:\n req: |\n // pre\n' } },
1123
1168
  }),
1124
1169
  ]),
1125
1170
  ]);
@@ -1130,13 +1175,13 @@ describe('plugin file merges', () => {
1130
1175
  expect(req.content).toContain('url:');
1131
1176
  });
1132
1177
 
1133
- it('leaves generated content unchanged when pluginFiles is absent', () => {
1178
+ it('leaves generated content unchanged when pluginExtensions is absent', () => {
1134
1179
  const withoutOverride = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200)] })])]);
1135
1180
  const withOverride = opRoot([
1136
1181
  opRoute('/users', [
1137
1182
  opOperation('get', {
1138
1183
  responses: [opResponse(200)],
1139
- pluginFiles: {},
1184
+ pluginExtensions: {},
1140
1185
  }),
1141
1186
  ]),
1142
1187
  ]);
@@ -1151,7 +1196,7 @@ describe('plugin file merges', () => {
1151
1196
  opRoute('/users', [
1152
1197
  opOperation('get', {
1153
1198
  responses: [opResponse(200)],
1154
- pluginFiles: { bruno: 'just a scalar string' },
1199
+ pluginExtensions: { bruno: { template: 'just a scalar string' } },
1155
1200
  }),
1156
1201
  ]),
1157
1202
  ]);
@@ -1164,7 +1209,7 @@ describe('plugin file merges', () => {
1164
1209
  const root = opRoot([
1165
1210
  opRoute('/users', [
1166
1211
  opOperation('get', {
1167
- pluginFiles: { bruno: 'info:\n name: Custom Name\n' },
1212
+ pluginExtensions: { bruno: { template: 'info:\n name: Custom Name\n' } },
1168
1213
  }),
1169
1214
  ]),
1170
1215
  ]);
@@ -1211,3 +1256,33 @@ describe('mergePluginFile', () => {
1211
1256
  });
1212
1257
  });
1213
1258
 
1259
+ describe('validateBrunoExtension', () => {
1260
+ it('accepts an empty object', () => {
1261
+ expect(validateBrunoExtension({})).toBeUndefined();
1262
+ });
1263
+
1264
+ it('accepts { template: <string> }', () => {
1265
+ expect(validateBrunoExtension({ template: 'runtime: {}' })).toBeUndefined();
1266
+ });
1267
+
1268
+ it('rejects a non-object value', () => {
1269
+ const result = validateBrunoExtension('foo') as { errors: string[] };
1270
+ expect(result.errors[0]).toContain('expected an object');
1271
+ });
1272
+
1273
+ it('rejects an array value', () => {
1274
+ const result = validateBrunoExtension(['x']) as { errors: string[] };
1275
+ expect(result.errors[0]).toContain('array');
1276
+ });
1277
+
1278
+ it('rejects template that is not a string', () => {
1279
+ const result = validateBrunoExtension({ template: 7 }) as { errors: string[] };
1280
+ expect(result.errors[0]).toContain("'template' must be a string");
1281
+ });
1282
+
1283
+ it('rejects unknown fields', () => {
1284
+ const result = validateBrunoExtension({ template: 'x', other: 'y' }) as { errors: string[] };
1285
+ expect(result.errors[0]).toContain("unknown field 'other'");
1286
+ });
1287
+ });
1288
+