usepaso 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/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +123 -0
- package/dist/cli.js.map +1 -0
- package/dist/generators/mcp.d.ts +12 -0
- package/dist/generators/mcp.d.ts.map +1 -0
- package/dist/generators/mcp.js +204 -0
- package/dist/generators/mcp.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +11 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +25 -0
- package/dist/parser.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validator.d.ts +7 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +185 -0
- package/dist/validator.js.map +1 -0
- package/package.json +31 -0
- package/src/cli.ts +132 -0
- package/src/generators/mcp.ts +229 -0
- package/src/index.ts +14 -0
- package/src/parser.ts +23 -0
- package/src/types.ts +67 -0
- package/src/validator.ts +188 -0
- package/tests/mcp.test.ts +119 -0
- package/tests/parser.test.ts +67 -0
- package/tests/validator.test.ts +133 -0
- package/tsconfig.json +19 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface PasoDeclaration {
|
|
2
|
+
version: string;
|
|
3
|
+
service: PasoService;
|
|
4
|
+
capabilities: PasoCapability[];
|
|
5
|
+
permissions?: PasoPermissions;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PasoService {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
base_url: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
auth?: PasoAuth;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PasoAuth {
|
|
17
|
+
type: 'api_key' | 'bearer' | 'oauth2' | 'none';
|
|
18
|
+
header?: string;
|
|
19
|
+
prefix?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PasoCapability {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
26
|
+
path: string;
|
|
27
|
+
permission: 'read' | 'write' | 'admin';
|
|
28
|
+
consent_required?: boolean;
|
|
29
|
+
inputs?: Record<string, PasoInput>;
|
|
30
|
+
output?: Record<string, PasoOutput>;
|
|
31
|
+
constraints?: PasoConstraint[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PasoInput {
|
|
35
|
+
type: 'string' | 'integer' | 'number' | 'boolean' | 'enum' | 'array' | 'object';
|
|
36
|
+
required?: boolean;
|
|
37
|
+
description: string;
|
|
38
|
+
values?: (string | number)[];
|
|
39
|
+
default?: unknown;
|
|
40
|
+
in?: 'query' | 'path' | 'body' | 'header';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PasoOutput {
|
|
44
|
+
type: 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array';
|
|
45
|
+
description?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PasoConstraint {
|
|
49
|
+
max_per_hour?: number;
|
|
50
|
+
max_per_request?: number;
|
|
51
|
+
max_value?: number;
|
|
52
|
+
allowed_values?: unknown[];
|
|
53
|
+
requires_field?: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PasoPermissions {
|
|
58
|
+
read?: string[];
|
|
59
|
+
write?: string[];
|
|
60
|
+
admin?: string[];
|
|
61
|
+
forbidden?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ValidationError {
|
|
65
|
+
path: string;
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { PasoDeclaration, PasoCapability, ValidationError } from './types';
|
|
2
|
+
|
|
3
|
+
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
4
|
+
const VALID_PERMISSIONS = ['read', 'write', 'admin'];
|
|
5
|
+
const VALID_INPUT_TYPES = ['string', 'integer', 'number', 'boolean', 'enum', 'array', 'object'];
|
|
6
|
+
const VALID_OUTPUT_TYPES = ['string', 'integer', 'number', 'boolean', 'object', 'array'];
|
|
7
|
+
const VALID_AUTH_TYPES = ['api_key', 'bearer', 'oauth2', 'none'];
|
|
8
|
+
const VALID_IN_VALUES = ['query', 'path', 'body', 'header'];
|
|
9
|
+
const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate a parsed PasoDeclaration against the spec.
|
|
13
|
+
* Returns an array of errors. Empty array = valid.
|
|
14
|
+
*/
|
|
15
|
+
export function validate(decl: PasoDeclaration): ValidationError[] {
|
|
16
|
+
const errors: ValidationError[] = [];
|
|
17
|
+
|
|
18
|
+
// Version
|
|
19
|
+
if (!decl.version) {
|
|
20
|
+
errors.push({ path: 'version', message: 'version is required' });
|
|
21
|
+
} else if (decl.version !== '1.0') {
|
|
22
|
+
errors.push({ path: 'version', message: 'version must be "1.0"' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Service
|
|
26
|
+
if (!decl.service) {
|
|
27
|
+
errors.push({ path: 'service', message: 'service is required' });
|
|
28
|
+
} else {
|
|
29
|
+
if (!decl.service.name) {
|
|
30
|
+
errors.push({ path: 'service.name', message: 'service.name is required' });
|
|
31
|
+
}
|
|
32
|
+
if (!decl.service.description) {
|
|
33
|
+
errors.push({ path: 'service.description', message: 'service.description is required' });
|
|
34
|
+
}
|
|
35
|
+
if (!decl.service.base_url) {
|
|
36
|
+
errors.push({ path: 'service.base_url', message: 'service.base_url is required' });
|
|
37
|
+
} else {
|
|
38
|
+
try {
|
|
39
|
+
new URL(decl.service.base_url);
|
|
40
|
+
} catch {
|
|
41
|
+
errors.push({ path: 'service.base_url', message: 'service.base_url must be a valid URL' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (decl.service.auth) {
|
|
45
|
+
if (!VALID_AUTH_TYPES.includes(decl.service.auth.type)) {
|
|
46
|
+
errors.push({ path: 'service.auth.type', message: `auth.type must be one of: ${VALID_AUTH_TYPES.join(', ')}` });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Capabilities
|
|
52
|
+
if (!decl.capabilities) {
|
|
53
|
+
errors.push({ path: 'capabilities', message: 'capabilities is required' });
|
|
54
|
+
} else if (!Array.isArray(decl.capabilities)) {
|
|
55
|
+
errors.push({ path: 'capabilities', message: 'capabilities must be an array' });
|
|
56
|
+
} else {
|
|
57
|
+
const names = new Set<string>();
|
|
58
|
+
decl.capabilities.forEach((cap, i) => {
|
|
59
|
+
const prefix = `capabilities[${i}]`;
|
|
60
|
+
errors.push(...validateCapability(cap, prefix, names));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Permissions
|
|
65
|
+
if (decl.permissions) {
|
|
66
|
+
const capNames = new Set((decl.capabilities || []).map(c => c.name));
|
|
67
|
+
const allReferenced = new Set<string>();
|
|
68
|
+
|
|
69
|
+
for (const tier of ['read', 'write', 'admin', 'forbidden'] as const) {
|
|
70
|
+
const list = decl.permissions[tier];
|
|
71
|
+
if (list) {
|
|
72
|
+
for (const name of list) {
|
|
73
|
+
// forbidden can reference capabilities not declared (to explicitly block API endpoints)
|
|
74
|
+
if (tier !== 'forbidden' && !capNames.has(name)) {
|
|
75
|
+
errors.push({ path: `permissions.${tier}`, message: `references unknown capability "${name}"` });
|
|
76
|
+
}
|
|
77
|
+
if (tier !== 'forbidden' && allReferenced.has(name)) {
|
|
78
|
+
// Check if it's in forbidden
|
|
79
|
+
}
|
|
80
|
+
allReferenced.add(name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check forbidden doesn't overlap with tiers
|
|
86
|
+
if (decl.permissions.forbidden) {
|
|
87
|
+
const tiered = new Set([
|
|
88
|
+
...(decl.permissions.read || []),
|
|
89
|
+
...(decl.permissions.write || []),
|
|
90
|
+
...(decl.permissions.admin || []),
|
|
91
|
+
]);
|
|
92
|
+
for (const name of decl.permissions.forbidden) {
|
|
93
|
+
if (tiered.has(name)) {
|
|
94
|
+
errors.push({ path: 'permissions.forbidden', message: `"${name}" cannot be both in a permission tier and forbidden` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateCapability(cap: PasoCapability, prefix: string, names: Set<string>): ValidationError[] {
|
|
104
|
+
const errors: ValidationError[] = [];
|
|
105
|
+
|
|
106
|
+
if (!cap.name) {
|
|
107
|
+
errors.push({ path: `${prefix}.name`, message: 'name is required' });
|
|
108
|
+
} else {
|
|
109
|
+
if (!SNAKE_CASE_RE.test(cap.name)) {
|
|
110
|
+
errors.push({ path: `${prefix}.name`, message: `"${cap.name}" must be snake_case` });
|
|
111
|
+
}
|
|
112
|
+
if (names.has(cap.name)) {
|
|
113
|
+
errors.push({ path: `${prefix}.name`, message: `duplicate capability name "${cap.name}"` });
|
|
114
|
+
}
|
|
115
|
+
names.add(cap.name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!cap.description) {
|
|
119
|
+
errors.push({ path: `${prefix}.description`, message: 'description is required' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!cap.method) {
|
|
123
|
+
errors.push({ path: `${prefix}.method`, message: 'method is required' });
|
|
124
|
+
} else if (!VALID_METHODS.includes(cap.method)) {
|
|
125
|
+
errors.push({ path: `${prefix}.method`, message: `method must be one of: ${VALID_METHODS.join(', ')}` });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!cap.path) {
|
|
129
|
+
errors.push({ path: `${prefix}.path`, message: 'path is required' });
|
|
130
|
+
} else if (!cap.path.startsWith('/')) {
|
|
131
|
+
errors.push({ path: `${prefix}.path`, message: 'path must start with /' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!cap.permission) {
|
|
135
|
+
errors.push({ path: `${prefix}.permission`, message: 'permission is required' });
|
|
136
|
+
} else if (!VALID_PERMISSIONS.includes(cap.permission)) {
|
|
137
|
+
errors.push({ path: `${prefix}.permission`, message: `permission must be one of: ${VALID_PERMISSIONS.join(', ')}` });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Validate inputs
|
|
141
|
+
if (cap.inputs) {
|
|
142
|
+
for (const [inputName, input] of Object.entries(cap.inputs)) {
|
|
143
|
+
const inputPrefix = `${prefix}.inputs.${inputName}`;
|
|
144
|
+
if (!input.type) {
|
|
145
|
+
errors.push({ path: inputPrefix, message: 'type is required' });
|
|
146
|
+
} else if (!VALID_INPUT_TYPES.includes(input.type)) {
|
|
147
|
+
errors.push({ path: inputPrefix, message: `type must be one of: ${VALID_INPUT_TYPES.join(', ')}` });
|
|
148
|
+
}
|
|
149
|
+
if (input.type === 'enum' && (!input.values || input.values.length === 0)) {
|
|
150
|
+
errors.push({ path: inputPrefix, message: 'enum type must have values defined' });
|
|
151
|
+
}
|
|
152
|
+
if (!input.description) {
|
|
153
|
+
errors.push({ path: inputPrefix, message: 'description is required' });
|
|
154
|
+
}
|
|
155
|
+
if (input.in && !VALID_IN_VALUES.includes(input.in)) {
|
|
156
|
+
errors.push({ path: `${inputPrefix}.in`, message: `in must be one of: ${VALID_IN_VALUES.join(', ')}` });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Validate path params exist in inputs
|
|
162
|
+
if (cap.path && cap.inputs) {
|
|
163
|
+
const pathParams = cap.path.match(/\{([^}]+)\}/g);
|
|
164
|
+
if (pathParams) {
|
|
165
|
+
for (const param of pathParams) {
|
|
166
|
+
const paramName = param.slice(1, -1);
|
|
167
|
+
if (!cap.inputs[paramName]) {
|
|
168
|
+
errors.push({ path: `${prefix}.path`, message: `path parameter "{${paramName}}" not found in inputs` });
|
|
169
|
+
} else if (cap.inputs[paramName].in && cap.inputs[paramName].in !== 'path') {
|
|
170
|
+
errors.push({ path: `${prefix}.inputs.${paramName}`, message: `path parameter must have in: path` });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Validate output
|
|
177
|
+
if (cap.output) {
|
|
178
|
+
for (const [fieldName, output] of Object.entries(cap.output)) {
|
|
179
|
+
if (!output.type) {
|
|
180
|
+
errors.push({ path: `${prefix}.output.${fieldName}`, message: 'type is required' });
|
|
181
|
+
} else if (!VALID_OUTPUT_TYPES.includes(output.type)) {
|
|
182
|
+
errors.push({ path: `${prefix}.output.${fieldName}`, message: `type must be one of: ${VALID_OUTPUT_TYPES.join(', ')}` });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return errors;
|
|
188
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateMcpServer } from '../src/generators/mcp';
|
|
3
|
+
import { parseFile } from '../src/parser';
|
|
4
|
+
import { PasoDeclaration } from '../src/types';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
function minimal(): PasoDeclaration {
|
|
8
|
+
return {
|
|
9
|
+
version: '1.0',
|
|
10
|
+
service: {
|
|
11
|
+
name: 'Test',
|
|
12
|
+
description: 'A test service',
|
|
13
|
+
base_url: 'https://api.test.com',
|
|
14
|
+
},
|
|
15
|
+
capabilities: [
|
|
16
|
+
{
|
|
17
|
+
name: 'get_item',
|
|
18
|
+
description: 'Get an item by ID',
|
|
19
|
+
method: 'GET',
|
|
20
|
+
path: '/items/{id}',
|
|
21
|
+
permission: 'read',
|
|
22
|
+
inputs: {
|
|
23
|
+
id: { type: 'string', required: true, description: 'Item ID', in: 'path' },
|
|
24
|
+
},
|
|
25
|
+
output: {
|
|
26
|
+
id: { type: 'string' },
|
|
27
|
+
name: { type: 'string' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'create_item',
|
|
32
|
+
description: 'Create a new item',
|
|
33
|
+
method: 'POST',
|
|
34
|
+
path: '/items',
|
|
35
|
+
permission: 'write',
|
|
36
|
+
consent_required: true,
|
|
37
|
+
inputs: {
|
|
38
|
+
name: { type: 'string', required: true, description: 'Item name' },
|
|
39
|
+
priority: { type: 'enum', description: 'Priority level', values: ['low', 'medium', 'high'] },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('generateMcpServer', () => {
|
|
47
|
+
it('creates an MCP server from a minimal declaration', () => {
|
|
48
|
+
const server = generateMcpServer(minimal());
|
|
49
|
+
expect(server).toBeDefined();
|
|
50
|
+
expect(server.server).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('registers tools for each capability', () => {
|
|
54
|
+
const server = generateMcpServer(minimal());
|
|
55
|
+
// The server should have registered 2 tools
|
|
56
|
+
// We can check via the internal state
|
|
57
|
+
expect((server as any)._registeredTools).toBeDefined();
|
|
58
|
+
expect(Object.keys((server as any)._registeredTools)).toHaveLength(2);
|
|
59
|
+
expect((server as any)._registeredTools['get_item']).toBeDefined();
|
|
60
|
+
expect((server as any)._registeredTools['create_item']).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('skips forbidden capabilities', () => {
|
|
64
|
+
const decl = minimal();
|
|
65
|
+
decl.permissions = { forbidden: ['create_item'] };
|
|
66
|
+
const server = generateMcpServer(decl);
|
|
67
|
+
const tools = Object.keys((server as any)._registeredTools);
|
|
68
|
+
expect(tools).toHaveLength(1);
|
|
69
|
+
expect(tools).toContain('get_item');
|
|
70
|
+
expect(tools).not.toContain('create_item');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('works with Sentry example', () => {
|
|
74
|
+
const decl = parseFile(join(__dirname, '../../../examples/sentry/paso.yaml'));
|
|
75
|
+
const server = generateMcpServer(decl);
|
|
76
|
+
const tools = Object.keys((server as any)._registeredTools);
|
|
77
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
78
|
+
expect(tools).toContain('list_issues');
|
|
79
|
+
expect(tools).toContain('resolve_issue');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('works with Stripe example (respects forbidden)', () => {
|
|
83
|
+
const decl = parseFile(join(__dirname, '../../../examples/stripe/paso.yaml'));
|
|
84
|
+
const server = generateMcpServer(decl);
|
|
85
|
+
const tools = Object.keys((server as any)._registeredTools);
|
|
86
|
+
expect(tools).toContain('list_customers');
|
|
87
|
+
expect(tools).toContain('create_payment_intent');
|
|
88
|
+
// delete_customer is in forbidden but not declared as a capability, so it's just not present
|
|
89
|
+
expect(tools).not.toContain('delete_customer');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('handles capabilities with no inputs', () => {
|
|
93
|
+
const decl: PasoDeclaration = {
|
|
94
|
+
version: '1.0',
|
|
95
|
+
service: {
|
|
96
|
+
name: 'Test',
|
|
97
|
+
description: 'Test',
|
|
98
|
+
base_url: 'https://api.test.com',
|
|
99
|
+
},
|
|
100
|
+
capabilities: [
|
|
101
|
+
{
|
|
102
|
+
name: 'health_check',
|
|
103
|
+
description: 'Check service health',
|
|
104
|
+
method: 'GET',
|
|
105
|
+
path: '/health',
|
|
106
|
+
permission: 'read',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
const server = generateMcpServer(decl);
|
|
111
|
+
expect(Object.keys((server as any)._registeredTools)).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('includes consent warning in tool description', () => {
|
|
115
|
+
const server = generateMcpServer(minimal());
|
|
116
|
+
const createTool = (server as any)._registeredTools['create_item'];
|
|
117
|
+
expect(createTool).toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseString, parseFile } from '../src/parser';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
describe('parseString', () => {
|
|
6
|
+
it('parses a minimal valid declaration', () => {
|
|
7
|
+
const yaml = `
|
|
8
|
+
version: "1.0"
|
|
9
|
+
service:
|
|
10
|
+
name: TestService
|
|
11
|
+
description: A test service
|
|
12
|
+
base_url: https://api.test.com
|
|
13
|
+
capabilities:
|
|
14
|
+
- name: get_item
|
|
15
|
+
description: Get an item
|
|
16
|
+
method: GET
|
|
17
|
+
path: /items/{id}
|
|
18
|
+
permission: read
|
|
19
|
+
inputs:
|
|
20
|
+
id:
|
|
21
|
+
type: string
|
|
22
|
+
required: true
|
|
23
|
+
description: Item ID
|
|
24
|
+
in: path
|
|
25
|
+
`;
|
|
26
|
+
const result = parseString(yaml);
|
|
27
|
+
expect(result.version).toBe('1.0');
|
|
28
|
+
expect(result.service.name).toBe('TestService');
|
|
29
|
+
expect(result.capabilities).toHaveLength(1);
|
|
30
|
+
expect(result.capabilities[0].name).toBe('get_item');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws on invalid YAML', () => {
|
|
34
|
+
expect(() => parseString('{')).toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('throws on non-object YAML', () => {
|
|
38
|
+
expect(() => parseString('hello')).toThrow('expected an object');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('parseFile', () => {
|
|
43
|
+
it('parses the Sentry example', () => {
|
|
44
|
+
const filePath = join(__dirname, '../../../examples/sentry/paso.yaml');
|
|
45
|
+
const result = parseFile(filePath);
|
|
46
|
+
expect(result.service.name).toBe('Sentry');
|
|
47
|
+
expect(result.capabilities.length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('parses the Stripe example', () => {
|
|
51
|
+
const filePath = join(__dirname, '../../../examples/stripe/paso.yaml');
|
|
52
|
+
const result = parseFile(filePath);
|
|
53
|
+
expect(result.service.name).toBe('Stripe');
|
|
54
|
+
expect(result.capabilities.length).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('parses the Linear example', () => {
|
|
58
|
+
const filePath = join(__dirname, '../../../examples/linear/paso.yaml');
|
|
59
|
+
const result = parseFile(filePath);
|
|
60
|
+
expect(result.service.name).toBe('Linear');
|
|
61
|
+
expect(result.capabilities.length).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('throws on missing file', () => {
|
|
65
|
+
expect(() => parseFile('/nonexistent/paso.yaml')).toThrow();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validate } from '../src/validator';
|
|
3
|
+
import { parseFile } from '../src/parser';
|
|
4
|
+
import { PasoDeclaration } from '../src/types';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
function minimal(): PasoDeclaration {
|
|
8
|
+
return {
|
|
9
|
+
version: '1.0',
|
|
10
|
+
service: {
|
|
11
|
+
name: 'Test',
|
|
12
|
+
description: 'A test service',
|
|
13
|
+
base_url: 'https://api.test.com',
|
|
14
|
+
},
|
|
15
|
+
capabilities: [
|
|
16
|
+
{
|
|
17
|
+
name: 'get_item',
|
|
18
|
+
description: 'Get an item',
|
|
19
|
+
method: 'GET',
|
|
20
|
+
path: '/items',
|
|
21
|
+
permission: 'read',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('validate', () => {
|
|
28
|
+
it('passes a minimal valid declaration', () => {
|
|
29
|
+
const errors = validate(minimal());
|
|
30
|
+
expect(errors).toHaveLength(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('fails on missing version', () => {
|
|
34
|
+
const decl = minimal();
|
|
35
|
+
(decl as any).version = undefined;
|
|
36
|
+
const errors = validate(decl);
|
|
37
|
+
expect(errors.some(e => e.path === 'version')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('fails on wrong version', () => {
|
|
41
|
+
const decl = minimal();
|
|
42
|
+
decl.version = '2.0';
|
|
43
|
+
const errors = validate(decl);
|
|
44
|
+
expect(errors.some(e => e.message.includes('must be "1.0"'))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('fails on missing service name', () => {
|
|
48
|
+
const decl = minimal();
|
|
49
|
+
(decl.service as any).name = '';
|
|
50
|
+
const errors = validate(decl);
|
|
51
|
+
expect(errors.some(e => e.path === 'service.name')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('fails on invalid base_url', () => {
|
|
55
|
+
const decl = minimal();
|
|
56
|
+
decl.service.base_url = 'not-a-url';
|
|
57
|
+
const errors = validate(decl);
|
|
58
|
+
expect(errors.some(e => e.path === 'service.base_url')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('fails on non-snake_case capability name', () => {
|
|
62
|
+
const decl = minimal();
|
|
63
|
+
decl.capabilities[0].name = 'GetItem';
|
|
64
|
+
const errors = validate(decl);
|
|
65
|
+
expect(errors.some(e => e.message.includes('snake_case'))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fails on duplicate capability names', () => {
|
|
69
|
+
const decl = minimal();
|
|
70
|
+
decl.capabilities.push({ ...decl.capabilities[0] });
|
|
71
|
+
const errors = validate(decl);
|
|
72
|
+
expect(errors.some(e => e.message.includes('duplicate'))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('fails on invalid HTTP method', () => {
|
|
76
|
+
const decl = minimal();
|
|
77
|
+
(decl.capabilities[0] as any).method = 'SEND';
|
|
78
|
+
const errors = validate(decl);
|
|
79
|
+
expect(errors.some(e => e.path.includes('method'))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('fails on path not starting with /', () => {
|
|
83
|
+
const decl = minimal();
|
|
84
|
+
decl.capabilities[0].path = 'items';
|
|
85
|
+
const errors = validate(decl);
|
|
86
|
+
expect(errors.some(e => e.message.includes('start with /'))).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('fails on enum input without values', () => {
|
|
90
|
+
const decl = minimal();
|
|
91
|
+
decl.capabilities[0].inputs = {
|
|
92
|
+
status: { type: 'enum', description: 'Status' },
|
|
93
|
+
};
|
|
94
|
+
const errors = validate(decl);
|
|
95
|
+
expect(errors.some(e => e.message.includes('enum type must have values'))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('fails on path parameter missing from inputs', () => {
|
|
99
|
+
const decl = minimal();
|
|
100
|
+
decl.capabilities[0].path = '/items/{item_id}';
|
|
101
|
+
decl.capabilities[0].inputs = {};
|
|
102
|
+
const errors = validate(decl);
|
|
103
|
+
expect(errors.some(e => e.message.includes('item_id'))).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('fails when forbidden overlaps with a tier', () => {
|
|
107
|
+
const decl = minimal();
|
|
108
|
+
decl.permissions = {
|
|
109
|
+
read: ['get_item'],
|
|
110
|
+
forbidden: ['get_item'],
|
|
111
|
+
};
|
|
112
|
+
const errors = validate(decl);
|
|
113
|
+
expect(errors.some(e => e.message.includes('cannot be both'))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('fails on unknown capability in permissions', () => {
|
|
117
|
+
const decl = minimal();
|
|
118
|
+
decl.permissions = {
|
|
119
|
+
read: ['nonexistent'],
|
|
120
|
+
};
|
|
121
|
+
const errors = validate(decl);
|
|
122
|
+
expect(errors.some(e => e.message.includes('unknown capability'))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('validates example files without errors', () => {
|
|
126
|
+
for (const example of ['sentry', 'stripe', 'linear']) {
|
|
127
|
+
const filePath = join(__dirname, `../../../examples/${example}/paso.yaml`);
|
|
128
|
+
const decl = parseFile(filePath);
|
|
129
|
+
const errors = validate(decl);
|
|
130
|
+
expect(errors, `${example} should have no validation errors: ${JSON.stringify(errors)}`).toHaveLength(0);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
19
|
+
}
|