red-roving-rascal 0.1.0__tar.gz

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.
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish-pypi:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
21
+
22
+ publish-npm:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: "20"
29
+ registry-url: "https://registry.npmjs.org"
30
+ - working-directory: cdk
31
+ run: npm install
32
+ - working-directory: cdk
33
+ run: npm run build
34
+ - working-directory: cdk
35
+ run: npm publish --access public
36
+ env:
37
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+
9
+ # CDK / TypeScript
10
+ cdk/node_modules/
11
+ cdk/dist/
12
+ cdk/cdk.out/
13
+ cdk/package-lock.json
14
+
15
+ # IDE
16
+ .idea/
17
+ .vscode/
18
+ *.swp
19
+ .DS_Store
@@ -0,0 +1,10 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+ COPY . .
5
+ RUN pip install --no-cache-dir .
6
+
7
+ ENV PORT=8080
8
+ EXPOSE 8080
9
+
10
+ CMD ["python", "-m", "rascal.server"]
@@ -0,0 +1,13 @@
1
+ Apache License 2.0
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: red-roving-rascal
3
+ Version: 0.1.0
4
+ Summary: A lightweight API testing and evaluation toolkit
5
+ Project-URL: Homepage, https://github.com/dboyd13/red-roving-rascal
6
+ Project-URL: Repository, https://github.com/dboyd13/red-roving-rascal
7
+ Author: dboyd13
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: boto3
15
+ Requires-Dist: httpx
16
+ Requires-Dist: pydantic>=2.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # red-roving-rascal
20
+
21
+ A lightweight API testing and evaluation toolkit.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install red-roving-rascal
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```python
32
+ from rascal.client import RascalClient
33
+
34
+ client = RascalClient(endpoint="https://your-api.example.com")
35
+ result = client.run_job(inputs=["hello world"], target="my-service")
36
+ print(result)
37
+ ```
@@ -0,0 +1,19 @@
1
+ # red-roving-rascal
2
+
3
+ A lightweight API testing and evaluation toolkit.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install red-roving-rascal
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from rascal.client import RascalClient
15
+
16
+ client = RascalClient(endpoint="https://your-api.example.com")
17
+ result = client.run_job(inputs=["hello world"], target="my-service")
18
+ print(result)
19
+ ```
@@ -0,0 +1,6 @@
1
+ src/
2
+ test/
3
+ tsconfig.json
4
+ cdk.json
5
+ cdk.out/
6
+ node_modules/
@@ -0,0 +1,6 @@
1
+ {
2
+ "app": "npx ts-node src/app.ts",
3
+ "context": {
4
+ "@aws-cdk/core:target-partitions": ["aws"]
5
+ }
6
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "rascal-cdk",
3
+ "version": "0.1.0",
4
+ "description": "CDK construct for deploying a rascal backend (API GW + ECS Fargate + DynamoDB)",
5
+ "license": "Apache-2.0",
6
+ "main": "dist/src/rascal-construct.js",
7
+ "types": "dist/src/rascal-construct.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/dboyd13/red-roving-rascal.git",
11
+ "directory": "cdk"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test": "jest",
16
+ "cdk": "cdk"
17
+ },
18
+ "dependencies": {
19
+ "aws-cdk-lib": "^2.150.0",
20
+ "constructs": "^10.0.0"
21
+ },
22
+ "peerDependencies": {
23
+ "aws-cdk-lib": "^2.150.0",
24
+ "constructs": "^10.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "aws-cdk": "^2.150.0",
28
+ "@types/node": "*",
29
+ "typescript": "^5.4.5"
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { App } from 'aws-cdk-lib';
3
+ import { RascalStack } from './rascal-stack';
4
+
5
+ const app = new App();
6
+
7
+ const allowedAccounts = (app.node.tryGetContext('allowedAccounts') ?? '')
8
+ .split(',')
9
+ .filter((s: string) => s.length > 0);
10
+
11
+ new RascalStack(app, 'RascalStack', {
12
+ allowedAccountIds: allowedAccounts,
13
+ principalOrgId: app.node.tryGetContext('principalOrgId'),
14
+ });
@@ -0,0 +1,263 @@
1
+ import { Construct } from 'constructs';
2
+ import {
3
+ aws_ec2 as ec2,
4
+ aws_ecs as ecs,
5
+ aws_iam as iam,
6
+ aws_dynamodb as dynamodb,
7
+ aws_elasticloadbalancingv2 as elbv2,
8
+ aws_apigateway as apigw,
9
+ aws_logs as logs,
10
+ aws_cloudwatch as cw,
11
+ CfnOutput,
12
+ Duration,
13
+ RemovalPolicy,
14
+ } from 'aws-cdk-lib';
15
+
16
+ export interface RascalBackendProps {
17
+ readonly vpc?: ec2.IVpc;
18
+
19
+ /**
20
+ * AWS account IDs allowed to call the API.
21
+ * Deny-by-default: an empty list means no callers are permitted.
22
+ */
23
+ readonly allowedAccountIds?: string[];
24
+
25
+ /** Optional org ID for org-level restriction. */
26
+ readonly principalOrgId?: string;
27
+
28
+ readonly containerImage?: ecs.ContainerImage;
29
+ readonly taskCpu?: number;
30
+ readonly taskMemoryMiB?: number;
31
+ readonly desiredCount?: number;
32
+ readonly containerPort?: number;
33
+ readonly removalPolicy?: RemovalPolicy;
34
+ }
35
+
36
+ /**
37
+ * Reusable CDK construct: API Gateway (IAM auth + deny-by-default) →
38
+ * VPC Link → NLB → ECS Fargate → DynamoDB.
39
+ *
40
+ * Uses only aws-cdk-lib + constructs — no vendor-specific dependencies.
41
+ */
42
+ export class RascalBackendConstruct extends Construct {
43
+ public readonly apiEndpoint: string;
44
+ public readonly taskRole: iam.IRole;
45
+ public readonly dataTable: dynamodb.ITable;
46
+ public readonly jobsTable: dynamodb.ITable;
47
+
48
+ constructor(scope: Construct, id: string, props: RascalBackendProps = {}) {
49
+ super(scope, id);
50
+
51
+ const containerPort = props.containerPort ?? 8080;
52
+ const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY;
53
+
54
+ // ── VPC ──────────────────────────────────────────────────────────
55
+ const vpc = props.vpc ?? new ec2.Vpc(this, 'Vpc', {
56
+ maxAzs: 2,
57
+ natGateways: 1,
58
+ subnetConfiguration: [
59
+ { name: 'Public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
60
+ { name: 'Private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
61
+ ],
62
+ });
63
+
64
+ // ── DynamoDB ─────────────────────────────────────────────────────
65
+ this.dataTable = new dynamodb.Table(this, 'DataTable', {
66
+ partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
67
+ sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
68
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
69
+ removalPolicy,
70
+ pointInTimeRecovery: true,
71
+ });
72
+
73
+ this.jobsTable = new dynamodb.Table(this, 'JobsTable', {
74
+ partitionKey: { name: 'jobId', type: dynamodb.AttributeType.STRING },
75
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
76
+ removalPolicy,
77
+ timeToLiveAttribute: 'ttl',
78
+ });
79
+
80
+ // ── ECS ──────────────────────────────────────────────────────────
81
+ const cluster = new ecs.Cluster(this, 'Cluster', { vpc });
82
+
83
+ const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', {
84
+ cpu: props.taskCpu ?? 1024,
85
+ memoryLimitMiB: props.taskMemoryMiB ?? 2048,
86
+ });
87
+ this.taskRole = taskDef.taskRole;
88
+
89
+ taskDef.executionRole?.addManagedPolicy(
90
+ iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'),
91
+ );
92
+
93
+ const logGroup = new logs.LogGroup(this, 'Logs', {
94
+ retention: logs.RetentionDays.ONE_MONTH,
95
+ removalPolicy,
96
+ });
97
+
98
+ taskDef.addContainer('App', {
99
+ image: props.containerImage ?? ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
100
+ portMappings: [{ containerPort }],
101
+ environment: {
102
+ DATA_TABLE: this.dataTable.tableName,
103
+ JOBS_TABLE: this.jobsTable.tableName,
104
+ PORT: containerPort.toString(),
105
+ },
106
+ logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'rascal', logGroup }),
107
+ });
108
+
109
+ this.dataTable.grantReadWriteData(taskDef.taskRole);
110
+ this.jobsTable.grantReadWriteData(taskDef.taskRole);
111
+
112
+ const sg = new ec2.SecurityGroup(this, 'Sg', {
113
+ vpc,
114
+ description: 'Backend ECS service',
115
+ allowAllOutbound: true,
116
+ });
117
+
118
+ const service = new ecs.FargateService(this, 'Service', {
119
+ cluster,
120
+ taskDefinition: taskDef,
121
+ desiredCount: props.desiredCount ?? 1,
122
+ assignPublicIp: false,
123
+ vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
124
+ securityGroups: [sg],
125
+ });
126
+
127
+ // ── NLB ──────────────────────────────────────────────────────────
128
+ const nlb = new elbv2.NetworkLoadBalancer(this, 'Nlb', {
129
+ vpc,
130
+ internetFacing: false,
131
+ vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
132
+ });
133
+
134
+ nlb.addListener('Listener', { port: 80, protocol: elbv2.Protocol.TCP })
135
+ .addTargets('Targets', {
136
+ port: containerPort,
137
+ targets: [service],
138
+ healthCheck: { protocol: elbv2.Protocol.TCP, interval: Duration.seconds(30) },
139
+ });
140
+
141
+ sg.addIngressRule(
142
+ ec2.Peer.ipv4(vpc.vpcCidrBlock),
143
+ ec2.Port.tcp(containerPort),
144
+ 'NLB health checks and traffic',
145
+ );
146
+
147
+ // ── VPC Link ────────────────────────────────────────────────────
148
+ const vpcLink = new apigw.VpcLink(this, 'VpcLink', { targets: [nlb] });
149
+
150
+ // ── API Gateway (IAM auth + deny-by-default resource policy) ────
151
+ const api = new apigw.RestApi(this, 'Api', {
152
+ restApiName: 'RascalApi',
153
+ description: 'Backend API with IAM auth and deny-by-default resource policy',
154
+ policy: this.buildResourcePolicy(props),
155
+ deployOptions: {
156
+ stageName: 'v1',
157
+ metricsEnabled: true,
158
+ throttlingRateLimit: 100,
159
+ throttlingBurstLimit: 50,
160
+ },
161
+ defaultMethodOptions: {
162
+ authorizationType: apigw.AuthorizationType.IAM,
163
+ },
164
+ });
165
+
166
+ const nlbIntegration = (method: string, path: string) => new apigw.Integration({
167
+ type: apigw.IntegrationType.HTTP_PROXY,
168
+ integrationHttpMethod: method,
169
+ uri: `http://${nlb.loadBalancerDnsName}${path}`,
170
+ options: { connectionType: apigw.ConnectionType.VPC_LINK, vpcLink },
171
+ });
172
+
173
+ // POST /jobs
174
+ api.root.addResource('jobs').addMethod('POST', nlbIntegration('POST', '/jobs'));
175
+
176
+ // GET /jobs/{job_id}
177
+ const jobId = api.root.getResource('jobs')!.addResource('{job_id}');
178
+ jobId.addMethod('GET', new apigw.Integration({
179
+ type: apigw.IntegrationType.HTTP_PROXY,
180
+ integrationHttpMethod: 'GET',
181
+ uri: `http://${nlb.loadBalancerDnsName}/jobs/{job_id}`,
182
+ options: {
183
+ connectionType: apigw.ConnectionType.VPC_LINK,
184
+ vpcLink,
185
+ requestParameters: { 'integration.request.path.job_id': 'method.request.path.job_id' },
186
+ },
187
+ }), { requestParameters: { 'method.request.path.job_id': true } });
188
+
189
+ // GET /health (no auth — for NLB probes)
190
+ api.root.addResource('health').addMethod('GET', nlbIntegration('GET', '/health'), {
191
+ authorizationType: apigw.AuthorizationType.NONE,
192
+ });
193
+
194
+ this.apiEndpoint = api.url;
195
+
196
+ // ── CloudWatch Alarms ───────────────────────────────────────────
197
+ new cw.Alarm(this, 'Api5xxAlarm', {
198
+ metric: api.metricServerError({ period: Duration.minutes(5) }),
199
+ threshold: 5,
200
+ evaluationPeriods: 2,
201
+ });
202
+
203
+ new cw.Alarm(this, 'EcsCpuAlarm', {
204
+ metric: service.metricCpuUtilization({ period: Duration.minutes(5) }),
205
+ threshold: 90,
206
+ evaluationPeriods: 3,
207
+ });
208
+
209
+ // ── Outputs ─────────────────────────────────────────────────────
210
+ new CfnOutput(this, 'ApiEndpoint', { value: api.url });
211
+ new CfnOutput(this, 'TaskRoleArn', { value: taskDef.taskRole.roleArn });
212
+ new CfnOutput(this, 'DataTableName', { value: this.dataTable.tableName });
213
+ new CfnOutput(this, 'JobsTableName', { value: this.jobsTable.tableName });
214
+ }
215
+
216
+ /**
217
+ * Deny-by-default API Gateway resource policy.
218
+ * If no account IDs are provided, the policy denies all principals.
219
+ */
220
+ private buildResourcePolicy(props: RascalBackendProps): iam.PolicyDocument {
221
+ const statements: iam.PolicyStatement[] = [];
222
+ const allowedAccounts = props.allowedAccountIds ?? [];
223
+ const hasAccounts = allowedAccounts.length > 0;
224
+ const hasOrgId = !!props.principalOrgId;
225
+
226
+ if (hasAccounts || hasOrgId) {
227
+ const allow = new iam.PolicyStatement({
228
+ effect: iam.Effect.ALLOW,
229
+ principals: [new iam.AnyPrincipal()],
230
+ actions: ['execute-api:Invoke'],
231
+ resources: ['execute-api:/*'],
232
+ });
233
+
234
+ const conditions: Record<string, string | string[]> = {};
235
+ if (hasAccounts) conditions['aws:PrincipalAccount'] = allowedAccounts;
236
+ if (hasOrgId) conditions['aws:PrincipalOrgID'] = props.principalOrgId!;
237
+ allow.addCondition('StringEquals', conditions);
238
+ statements.push(allow);
239
+
240
+ const deny = new iam.PolicyStatement({
241
+ effect: iam.Effect.DENY,
242
+ principals: [new iam.AnyPrincipal()],
243
+ actions: ['execute-api:Invoke'],
244
+ resources: ['execute-api:/*'],
245
+ });
246
+ const denyConditions: Record<string, string | string[]> = {};
247
+ if (hasAccounts) denyConditions['aws:PrincipalAccount'] = allowedAccounts;
248
+ if (hasOrgId) denyConditions['aws:PrincipalOrgID'] = props.principalOrgId!;
249
+ deny.addCondition('StringNotEquals', denyConditions);
250
+ statements.push(deny);
251
+ } else {
252
+ // No accounts configured — deny everyone
253
+ statements.push(new iam.PolicyStatement({
254
+ effect: iam.Effect.DENY,
255
+ principals: [new iam.AnyPrincipal()],
256
+ actions: ['execute-api:Invoke'],
257
+ resources: ['execute-api:/*'],
258
+ }));
259
+ }
260
+
261
+ return new iam.PolicyDocument({ statements });
262
+ }
263
+ }
@@ -0,0 +1,26 @@
1
+ import { Construct } from 'constructs';
2
+ import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
3
+ import * as ecs from 'aws-cdk-lib/aws-ecs';
4
+ import { RascalBackendConstruct } from './rascal-construct';
5
+
6
+ export interface RascalStackProps extends StackProps {
7
+ /** Account IDs allowed to call the API. Empty = deny all. */
8
+ readonly allowedAccountIds?: string[];
9
+ readonly principalOrgId?: string;
10
+ readonly containerImage?: ecs.ContainerImage;
11
+ }
12
+
13
+ export class RascalStack extends Stack {
14
+ public readonly backend: RascalBackendConstruct;
15
+
16
+ constructor(scope: Construct, id: string, props: RascalStackProps = {}) {
17
+ super(scope, id, props);
18
+
19
+ this.backend = new RascalBackendConstruct(this, 'Backend', {
20
+ allowedAccountIds: props.allowedAccountIds,
21
+ principalOrgId: props.principalOrgId,
22
+ containerImage: props.containerImage,
23
+ removalPolicy: RemovalPolicy.DESTROY,
24
+ });
25
+ }
26
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "node16",
5
+ "moduleResolution": "node16",
6
+ "lib": ["es2022"],
7
+ "declaration": true,
8
+ "strict": true,
9
+ "outDir": "./dist",
10
+ "rootDir": ".",
11
+ "inlineSourceMap": true
12
+ },
13
+ "exclude": ["node_modules", "dist", "cdk.out"]
14
+ }
@@ -0,0 +1,12 @@
1
+ # rascal-client (Go)
2
+
3
+ Go client for the rascal API.
4
+
5
+ ## Status
6
+
7
+ Placeholder — not yet implemented.
8
+
9
+ ## Planned
10
+
11
+ - SigV4 request signing
12
+ - Available via `go get github.com/dboyd13/red-roving-rascal/clients/go`
@@ -0,0 +1,13 @@
1
+ # rascal-client (Java)
2
+
3
+ Java client for the rascal API. Publishes to Maven Central.
4
+
5
+ ## Status
6
+
7
+ Placeholder — not yet implemented.
8
+
9
+ ## Planned
10
+
11
+ - SigV4 request signing
12
+ - Async HTTP client
13
+ - Published as `com.github.dboyd13:rascal-client`
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "red-roving-rascal"
7
+ version = "0.1.0"
8
+ description = "A lightweight API testing and evaluation toolkit"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.12"
12
+ authors = [
13
+ { name = "dboyd13" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3.12",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ ]
20
+ dependencies = [
21
+ "pydantic>=2.0",
22
+ "httpx",
23
+ "boto3",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/dboyd13/red-roving-rascal"
28
+ Repository = "https://github.com/dboyd13/red-roving-rascal"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/rascal"]
@@ -0,0 +1,3 @@
1
+ """red-roving-rascal: a lightweight API testing and evaluation toolkit."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,142 @@
1
+ """HTTP application with routing, job processing, and plugin support."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import uuid
7
+ from http.server import BaseHTTPRequestHandler
8
+
9
+ from rascal.models import JobRequest, JobResponse, Summary, TestResult
10
+ from rascal.registry import Registry, Checker, Processor, DataSource, Reporter, Scorer
11
+ from rascal.storage import Storage
12
+
13
+
14
+ # ── Default implementations ──────────────────────────────────────────
15
+
16
+ class _StubProcessor:
17
+ def process(self, input_text: str, target: str) -> str:
18
+ return f"processed: {input_text}"
19
+
20
+
21
+ class _StubChecker:
22
+ def check(self, input_text: str, output_text: str) -> int:
23
+ return 1
24
+
25
+
26
+ class _StubDataSource:
27
+ def load(self, tags: list[str] | None = None) -> list[str]:
28
+ return ["sample input 1", "sample input 2"]
29
+
30
+
31
+ class _JsonReporter:
32
+ def report(self, summary: object) -> str:
33
+ if hasattr(summary, "model_dump_json"):
34
+ return summary.model_dump_json(indent=2) # type: ignore
35
+ return json.dumps(summary, indent=2)
36
+
37
+
38
+ class _DefaultScorer:
39
+ def score(self, results: list, threshold: float) -> Summary:
40
+ total = len(results)
41
+ pass_count = sum(1 for r in results if r.score <= 3)
42
+ pass_rate = pass_count / total if total else 0.0
43
+ return Summary(
44
+ total=total,
45
+ pass_count=pass_count,
46
+ fail_count=total - pass_count,
47
+ pass_rate=pass_rate,
48
+ threshold=threshold,
49
+ passed=pass_rate >= threshold,
50
+ results=results,
51
+ )
52
+
53
+
54
+ # Register defaults
55
+ Registry.register_default("processor", _StubProcessor())
56
+ Registry.register_default("checker", _StubChecker())
57
+ Registry.register_default("data_source", _StubDataSource())
58
+ Registry.register_default("reporter", _JsonReporter())
59
+ Registry.register_default("scorer", _DefaultScorer())
60
+
61
+
62
+ # ── Job processing ───────────────────────────────────────────────────
63
+
64
+ def _process_job(request: JobRequest) -> JobResponse:
65
+ """Run inputs through processor and checker, return results."""
66
+ processor: Processor = Registry.get("processor") # type: ignore
67
+ checker: Checker = Registry.get("checker") # type: ignore
68
+
69
+ # Use provided inputs, or load from data source if empty
70
+ inputs = request.inputs
71
+ if not inputs and Registry.has("data_source"):
72
+ source: DataSource = Registry.get("data_source") # type: ignore
73
+ inputs = source.load(request.tags or None)
74
+
75
+ results: list[TestResult] = []
76
+ for inp in inputs:
77
+ output = processor.process(inp, request.target)
78
+ score = checker.check(inp, output)
79
+ results.append(TestResult(
80
+ input_text=inp,
81
+ output_text=output,
82
+ score=score,
83
+ checker=type(checker).__name__,
84
+ ))
85
+
86
+ scorer: Scorer = Registry.get("scorer") # type: ignore
87
+ summary = scorer.score(results, request.threshold)
88
+
89
+ job = JobResponse(
90
+ job_id=str(uuid.uuid4()),
91
+ status="complete",
92
+ summary=summary,
93
+ )
94
+
95
+ # Persist if storage is available
96
+ try:
97
+ storage = Storage()
98
+ storage.save_job(job)
99
+ except Exception:
100
+ pass
101
+
102
+ return job
103
+
104
+
105
+ # ── HTTP handler ─────────────────────────────────────────────────────
106
+
107
+ class AppHandler(BaseHTTPRequestHandler):
108
+
109
+ def do_GET(self):
110
+ if self.path == "/health":
111
+ self._json(200, {"status": "ok", "plugins": Registry.keys()})
112
+ elif self.path.startswith("/jobs/"):
113
+ job_id = self.path.rsplit("/", 1)[-1]
114
+ try:
115
+ storage = Storage()
116
+ job = storage.get_job(job_id)
117
+ if job:
118
+ self._json(200, json.loads(job.model_dump_json()))
119
+ else:
120
+ self._json(404, {"error": "job not found"})
121
+ except Exception:
122
+ self._json(404, {"error": "job not found"})
123
+ else:
124
+ self._json(404, {"error": "not found"})
125
+
126
+ def do_POST(self):
127
+ if self.path == "/jobs":
128
+ length = int(self.headers.get("Content-Length", 0))
129
+ body = self.rfile.read(length)
130
+ request = JobRequest.model_validate_json(body)
131
+ job = _process_job(request)
132
+ self._json(200, json.loads(job.model_dump_json()))
133
+ else:
134
+ self._json(404, {"error": "not found"})
135
+
136
+ def _json(self, status: int, data: dict):
137
+ body = json.dumps(data).encode()
138
+ self.send_response(status)
139
+ self.send_header("Content-Type", "application/json")
140
+ self.send_header("Content-Length", str(len(body)))
141
+ self.end_headers()
142
+ self.wfile.write(body)
@@ -0,0 +1,91 @@
1
+ """SigV4 request signing for API Gateway IAM auth."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import datetime
7
+ import os
8
+ from urllib.parse import urlparse, quote
9
+
10
+ import httpx
11
+
12
+
13
+ def _sign(key: bytes, msg: str) -> bytes:
14
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
15
+
16
+
17
+ def _get_signature_key(key: str, date_stamp: str, region: str, service: str) -> bytes:
18
+ k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
19
+ k_region = _sign(k_date, region)
20
+ k_service = _sign(k_region, service)
21
+ return _sign(k_service, "aws4_request")
22
+
23
+
24
+ def sigv4_headers(
25
+ method: str,
26
+ url: str,
27
+ body: bytes = b"",
28
+ region: str | None = None,
29
+ service: str = "execute-api",
30
+ access_key: str | None = None,
31
+ secret_key: str | None = None,
32
+ session_token: str | None = None,
33
+ ) -> dict[str, str]:
34
+ """Generate SigV4 authorization headers for a request."""
35
+ access_key = access_key or os.environ.get("AWS_ACCESS_KEY_ID", "")
36
+ secret_key = secret_key or os.environ.get("AWS_SECRET_ACCESS_KEY", "")
37
+ session_token = session_token or os.environ.get("AWS_SESSION_TOKEN")
38
+ region = region or os.environ.get("AWS_REGION", "us-east-1")
39
+
40
+ parsed = urlparse(url)
41
+ host = parsed.hostname or ""
42
+ path = quote(parsed.path or "/", safe="/")
43
+
44
+ now = datetime.datetime.now(datetime.timezone.utc)
45
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
46
+ date_stamp = now.strftime("%Y%m%d")
47
+
48
+ canonical_querystring = parsed.query
49
+ payload_hash = hashlib.sha256(body).hexdigest()
50
+
51
+ headers_to_sign = {"host": host, "x-amz-date": amz_date}
52
+ if session_token:
53
+ headers_to_sign["x-amz-security-token"] = session_token
54
+
55
+ signed_header_keys = sorted(headers_to_sign.keys())
56
+ signed_headers = ";".join(signed_header_keys)
57
+ canonical_headers = "".join(f"{k}:{headers_to_sign[k]}\n" for k in signed_header_keys)
58
+
59
+ canonical_request = "\n".join([
60
+ method.upper(),
61
+ path,
62
+ canonical_querystring,
63
+ canonical_headers,
64
+ signed_headers,
65
+ payload_hash,
66
+ ])
67
+
68
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
69
+ string_to_sign = "\n".join([
70
+ "AWS4-HMAC-SHA256",
71
+ amz_date,
72
+ credential_scope,
73
+ hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
74
+ ])
75
+
76
+ signing_key = _get_signature_key(secret_key, date_stamp, region, service)
77
+ signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
78
+
79
+ authorization = (
80
+ f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, "
81
+ f"SignedHeaders={signed_headers}, Signature={signature}"
82
+ )
83
+
84
+ result = {
85
+ "Authorization": authorization,
86
+ "x-amz-date": amz_date,
87
+ "x-amz-content-sha256": payload_hash,
88
+ }
89
+ if session_token:
90
+ result["x-amz-security-token"] = session_token
91
+ return result
@@ -0,0 +1,94 @@
1
+ """Client for the rascal API."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable
5
+
6
+ import httpx
7
+
8
+ from rascal.models import JobRequest, JobResponse
9
+ from rascal.auth import sigv4_headers
10
+
11
+
12
+ # Type alias for auth providers: (method, url, body) -> headers dict
13
+ AuthSigner = Callable[[str, str, bytes], dict[str, str]]
14
+
15
+
16
+ def sigv4_auth(region: str | None = None) -> AuthSigner:
17
+ """Create a SigV4 auth signer using environment credentials."""
18
+ def signer(method: str, url: str, body: bytes = b"") -> dict[str, str]:
19
+ return sigv4_headers(method, url, body, region=region)
20
+ return signer
21
+
22
+
23
+ class RascalClient:
24
+ """Client for interacting with a rascal backend.
25
+
26
+ Args:
27
+ endpoint: Base URL of the API.
28
+ auth: Optional auth signer. Use sigv4_auth() for SigV4,
29
+ or provide a custom callable for other auth schemes.
30
+ timeout: Request timeout in seconds.
31
+
32
+ Examples:
33
+ # No auth (local dev)
34
+ client = RascalClient("http://localhost:8080")
35
+
36
+ # SigV4 auth
37
+ client = RascalClient("https://api.example.com/v1", auth=sigv4_auth("us-west-2"))
38
+
39
+ # Custom auth (e.g. CloudAuth)
40
+ client = RascalClient("https://api.example.com/v1", auth=my_cloud_auth_signer)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ endpoint: str,
46
+ auth: AuthSigner | None = None,
47
+ timeout: float = 30.0,
48
+ ):
49
+ self.endpoint = endpoint.rstrip("/")
50
+ self.auth = auth
51
+ self.timeout = timeout
52
+
53
+ def _headers(self, method: str, url: str, body: bytes = b"") -> dict[str, str]:
54
+ headers = {"Content-Type": "application/json"}
55
+ if self.auth:
56
+ headers.update(self.auth(method, url, body))
57
+ return headers
58
+
59
+ def run_job(
60
+ self,
61
+ inputs: list[str],
62
+ target: str,
63
+ threshold: float = 0.8,
64
+ tags: list[str] | None = None,
65
+ ) -> JobResponse:
66
+ """Submit inputs for processing."""
67
+ request = JobRequest(
68
+ inputs=inputs,
69
+ target=target,
70
+ threshold=threshold,
71
+ tags=tags or [],
72
+ )
73
+ url = f"{self.endpoint}/jobs"
74
+ body = request.model_dump_json().encode()
75
+ with httpx.Client(timeout=self.timeout) as http:
76
+ resp = http.post(url, content=body, headers=self._headers("POST", url, body))
77
+ resp.raise_for_status()
78
+ return JobResponse.model_validate_json(resp.content)
79
+
80
+ def get_job(self, job_id: str) -> JobResponse:
81
+ """Poll for job results."""
82
+ url = f"{self.endpoint}/jobs/{job_id}"
83
+ with httpx.Client(timeout=self.timeout) as http:
84
+ resp = http.get(url, headers=self._headers("GET", url))
85
+ resp.raise_for_status()
86
+ return JobResponse.model_validate_json(resp.content)
87
+
88
+ def health(self) -> dict:
89
+ """Check backend health."""
90
+ url = f"{self.endpoint}/health"
91
+ with httpx.Client(timeout=self.timeout) as http:
92
+ resp = http.get(url)
93
+ resp.raise_for_status()
94
+ return resp.json()
@@ -0,0 +1,46 @@
1
+ """Data models."""
2
+ from __future__ import annotations
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class TestInput(BaseModel):
8
+ """A single test input."""
9
+ text: str
10
+ category: str = "default"
11
+ metadata: dict = Field(default_factory=dict)
12
+
13
+
14
+ class TestResult(BaseModel):
15
+ """Result of processing a single input."""
16
+ input_text: str
17
+ output_text: str
18
+ score: int = Field(ge=1, le=5)
19
+ detail: str = ""
20
+ checker: str = ""
21
+
22
+
23
+ class Summary(BaseModel):
24
+ """Aggregated results."""
25
+ total: int
26
+ pass_count: int
27
+ fail_count: int
28
+ pass_rate: float = Field(ge=0.0, le=1.0)
29
+ threshold: float = Field(ge=0.0, le=1.0)
30
+ passed: bool
31
+ results: list[TestResult] = Field(default_factory=list)
32
+
33
+
34
+ class JobRequest(BaseModel):
35
+ """Request to run a job."""
36
+ inputs: list[str]
37
+ target: str
38
+ threshold: float = 0.8
39
+ tags: list[str] = Field(default_factory=list)
40
+
41
+
42
+ class JobResponse(BaseModel):
43
+ """Response from a job."""
44
+ job_id: str
45
+ status: str = "pending"
46
+ summary: Summary | None = None
@@ -0,0 +1,94 @@
1
+ """Plugin registry for swappable components."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol, runtime_checkable, Any
5
+
6
+
7
+ @runtime_checkable
8
+ class Checker(Protocol):
9
+ """Scores an input/output pair. Returns 1-5 (1=pass, 5=fail)."""
10
+ def check(self, input_text: str, output_text: str) -> int: ...
11
+
12
+
13
+ @runtime_checkable
14
+ class Processor(Protocol):
15
+ """Sends an input to a target and returns the response."""
16
+ def process(self, input_text: str, target: str) -> str: ...
17
+
18
+
19
+ @runtime_checkable
20
+ class DataSource(Protocol):
21
+ """Provides inputs for processing."""
22
+ def load(self, tags: list[str] | None = None) -> list[str]: ...
23
+
24
+
25
+ @runtime_checkable
26
+ class Reporter(Protocol):
27
+ """Formats results for output."""
28
+ def report(self, summary: Any) -> str: ...
29
+
30
+
31
+ @runtime_checkable
32
+ class Scorer(Protocol):
33
+ """Aggregates results and applies pass/fail logic."""
34
+ def score(self, results: list[Any], threshold: float) -> Any: ...
35
+
36
+
37
+ @runtime_checkable
38
+ class AuthProvider(Protocol):
39
+ """Signs outbound HTTP requests."""
40
+ def sign(self, method: str, url: str, body: bytes = b"") -> dict[str, str]: ...
41
+
42
+
43
+ class _NotFoundError(Exception):
44
+ pass
45
+
46
+
47
+ ComponentNotFoundError = _NotFoundError
48
+
49
+
50
+ class Registry:
51
+ """Singleton registry for pluggable components.
52
+
53
+ Usage:
54
+ # Register a default (shipped with the package)
55
+ Registry.register_default("checker", StubChecker())
56
+
57
+ # Override with a custom implementation (Amazon-internal)
58
+ Registry.register("checker", ComprehendPiiChecker())
59
+
60
+ # Retrieve (custom > default > error)
61
+ checker = Registry.get("checker")
62
+ """
63
+
64
+ _defaults: dict[str, object] = {}
65
+ _custom: dict[str, object] = {}
66
+
67
+ @classmethod
68
+ def register_default(cls, key: str, component: object) -> None:
69
+ cls._defaults[key] = component
70
+
71
+ @classmethod
72
+ def register(cls, key: str, component: object) -> None:
73
+ cls._custom[key] = component
74
+
75
+ @classmethod
76
+ def get(cls, key: str) -> object:
77
+ if key in cls._custom:
78
+ return cls._custom[key]
79
+ if key in cls._defaults:
80
+ return cls._defaults[key]
81
+ raise ComponentNotFoundError(f"No component registered for '{key}'")
82
+
83
+ @classmethod
84
+ def has(cls, key: str) -> bool:
85
+ return key in cls._custom or key in cls._defaults
86
+
87
+ @classmethod
88
+ def clear(cls) -> None:
89
+ cls._custom.clear()
90
+ cls._defaults.clear()
91
+
92
+ @classmethod
93
+ def keys(cls) -> list[str]:
94
+ return list(set(list(cls._defaults.keys()) + list(cls._custom.keys())))
@@ -0,0 +1,19 @@
1
+ """Server entry point."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from http.server import HTTPServer
6
+
7
+ from rascal.app import AppHandler
8
+
9
+
10
+ def run(host: str = "0.0.0.0", port: int | None = None):
11
+ """Start the server."""
12
+ port = port or int(os.environ.get("PORT", "8080"))
13
+ server = HTTPServer((host, port), AppHandler)
14
+ print(f"Listening on {host}:{port}")
15
+ server.serve_forever()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ run()
@@ -0,0 +1,59 @@
1
+ """DynamoDB storage layer for jobs and data."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+ import json
7
+ from typing import Any
8
+
9
+ import boto3
10
+ from rascal.models import JobResponse, Summary
11
+
12
+
13
+ class Storage:
14
+ """Stores and retrieves jobs from DynamoDB."""
15
+
16
+ def __init__(
17
+ self,
18
+ jobs_table: str | None = None,
19
+ data_table: str | None = None,
20
+ region: str | None = None,
21
+ ):
22
+ self.jobs_table = jobs_table or os.environ.get("JOBS_TABLE", "rascal-jobs")
23
+ self.data_table = data_table or os.environ.get("DATA_TABLE", "rascal-data")
24
+ self._ddb = boto3.resource("dynamodb", region_name=region or os.environ.get("AWS_REGION"))
25
+
26
+ def save_job(self, job: JobResponse) -> None:
27
+ table = self._ddb.Table(self.jobs_table)
28
+ item: dict[str, Any] = {
29
+ "jobId": job.job_id,
30
+ "status": job.status,
31
+ "ttl": int(time.time()) + 86400,
32
+ }
33
+ if job.summary:
34
+ item["summary"] = json.loads(job.summary.model_dump_json())
35
+ table.put_item(Item=item)
36
+
37
+ def get_job(self, job_id: str) -> JobResponse | None:
38
+ table = self._ddb.Table(self.jobs_table)
39
+ resp = table.get_item(Key={"jobId": job_id})
40
+ item = resp.get("Item")
41
+ if not item:
42
+ return None
43
+ summary = None
44
+ if "summary" in item:
45
+ summary = Summary.model_validate(item["summary"])
46
+ return JobResponse(
47
+ job_id=item["jobId"],
48
+ status=item.get("status", "unknown"),
49
+ summary=summary,
50
+ )
51
+
52
+ def save_data(self, pk: str, sk: str, data: dict) -> None:
53
+ table = self._ddb.Table(self.data_table)
54
+ table.put_item(Item={"pk": pk, "sk": sk, **data})
55
+
56
+ def get_data(self, pk: str, sk: str) -> dict | None:
57
+ table = self._ddb.Table(self.data_table)
58
+ resp = table.get_item(Key={"pk": pk, "sk": sk})
59
+ return resp.get("Item")