File Coverage

bin/swaig-test
Criterion Covered Total %
statement 36 143 25.1
branch 6 66 9.0
condition 3 34 8.8
subroutine 8 14 57.1
pod n/a
total 53 257 20.6


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2             # Copyright (c) 2025 SignalWire
3             # Licensed under the MIT License.
4             #
5             # swaig-test - CLI tool for testing SWAIG agent endpoints
6             #
7             # Usage:
8             # swaig-test --url http://user:pass@host:port/route --dump-swml
9             # swaig-test --url http://user:pass@host:port/route --list-tools
10             # swaig-test --url http://user:pass@host:port/route --exec tool_name --param key=value
11             #
12              
13 3     3   16821 use strict;
  3         7  
  3         135  
14 3     3   17 use warnings;
  3         4  
  3         189  
15 3     3   2596 use Getopt::Long;
  3         57335  
  3         17  
16 3     3   2644 use JSON ();
  3         36363  
  3         121  
17 3     3   2687 use HTTP::Tiny;
  3         176034  
  3         138  
18 3     3   1590 use URI;
  3         17525  
  3         145  
19 3     3   1521 use MIME::Base64 qw(encode_base64);
  3         2437  
  3         289167  
20              
21 3         318480 my $VERSION = '1.0.0';
22              
23             # --- Parse CLI options ---
24 3         12 my ($url, $dump_swml, $list_tools, $exec_name, @params, $raw, $verbose, $help);
25              
26 3 50       51 GetOptions(
27             'url=s' => \$url,
28             'dump-swml' => \$dump_swml,
29             'list-tools' => \$list_tools,
30             'exec=s' => \$exec_name,
31             'param=s@' => \@params,
32             'raw' => \$raw,
33             'verbose' => \$verbose,
34             'help|h' => \$help,
35             ) or usage_exit();
36              
37 3 100       7115 if ($help) {
38 1         8 usage_exit(0);
39             }
40              
41 2 100       8 unless ($url) {
42 1         38 print STDERR "Error: --url is required\n\n";
43 1         7 usage_exit(1);
44             }
45              
46 1 50 33     12 unless ($dump_swml || $list_tools || $exec_name) {
      33        
47 1         33 print STDERR "Error: one of --dump-swml, --list-tools, or --exec NAME is required\n\n";
48 1         7 usage_exit(1);
49             }
50              
51             # --- Parse URL and extract auth ---
52 0         0 my $uri = URI->new($url);
53 0   0     0 my $userinfo = $uri->userinfo // '';
54 0         0 my ($auth_user, $auth_pass) = split(/:/, $userinfo, 2);
55              
56             # Build base URL without credentials
57 0         0 my $clean_uri = $uri->clone;
58 0         0 $clean_uri->userinfo(undef);
59 0         0 my $base_url = $clean_uri->as_string;
60              
61             # Remove trailing slash
62 0         0 $base_url =~ s{/$}{};
63              
64 0         0 my $http = HTTP::Tiny->new(timeout => 30);
65              
66             # --- Execute the requested operation ---
67              
68 0 0       0 if ($dump_swml) {
    0          
    0          
69 0         0 do_dump_swml();
70             }
71             elsif ($list_tools) {
72 0         0 do_list_tools();
73             }
74             elsif ($exec_name) {
75 0         0 do_exec($exec_name);
76             }
77              
78 0         0 exit 0;
79              
80             # ============================================================
81             # Operations
82             # ============================================================
83              
84             sub do_dump_swml {
85 0     0   0 my $response = http_get($base_url);
86 0 0       0 if ($raw) {
87 0         0 print $response->{content};
88 0 0       0 print "\n" unless $response->{content} =~ /\n$/;
89             } else {
90 0         0 my $data = eval { JSON::decode_json($response->{content}) };
  0         0  
91 0 0       0 if ($@) {
92 0         0 die "Error: Failed to parse JSON response: $@\n";
93             }
94 0         0 print JSON->new->utf8->pretty->canonical->encode($data);
95             }
96             }
97              
98             sub do_list_tools {
99 0     0   0 my $response = http_get($base_url);
100 0         0 my $data = eval { JSON::decode_json($response->{content}) };
  0         0  
101 0 0       0 if ($@) {
102 0         0 die "Error: Failed to parse JSON response: $@\n";
103             }
104              
105             # Extract SWAIG functions from the SWML document
106 0         0 my @functions;
107              
108             # Navigate the SWML structure to find AI verb functions
109 0 0       0 if (my $sections = $data->{sections}) {
110 0         0 for my $section_name (keys %$sections) {
111 0         0 my $verbs = $sections->{$section_name};
112 0 0       0 next unless ref $verbs eq 'ARRAY';
113 0         0 for my $verb (@$verbs) {
114 0 0 0     0 if (ref $verb eq 'HASH' && exists $verb->{ai}) {
115 0         0 my $ai = $verb->{ai};
116 0 0       0 if (my $swaig = $ai->{SWAIG}) {
117 0 0       0 if (my $funcs = $swaig->{functions}) {
118 0         0 push @functions, @$funcs;
119             }
120             }
121             }
122             }
123             }
124             }
125              
126 0 0       0 if (!@functions) {
127 0         0 print "No SWAIG functions found.\n";
128 0         0 return;
129             }
130              
131 0 0       0 if ($raw) {
132 0         0 for my $f (@functions) {
133 0   0     0 printf "%s\t%s\n", $f->{function} // 'unnamed', $f->{description} // '';
      0        
134             }
135             } else {
136 0         0 printf "Found %d SWAIG function(s):\n\n", scalar @functions;
137 0         0 for my $f (@functions) {
138 0   0     0 my $name = $f->{function} // 'unnamed';
139 0   0     0 my $desc = $f->{description} // '(no description)';
140 0         0 printf " %-30s %s\n", $name, $desc;
141              
142             # Show parameters if any
143 0 0       0 if (my $params_schema = $f->{parameters}) {
144 0 0       0 if (my $props = $params_schema->{properties}) {
145 0   0     0 my $required = $params_schema->{required} // [];
146 0         0 my %required_map = map { $_ => 1 } @$required;
  0         0  
147 0         0 for my $pname (sort keys %$props) {
148 0   0     0 my $ptype = $props->{$pname}{type} // 'any';
149 0   0     0 my $pdesc = $props->{$pname}{description} // '';
150 0 0       0 my $req_marker = $required_map{$pname} ? '*' : ' ';
151 0         0 printf " %s %-20s %-10s %s\n", $req_marker, $pname, "($ptype)", $pdesc;
152             }
153             }
154             }
155 0         0 print "\n";
156             }
157             }
158             }
159              
160             sub do_exec {
161 0     0   0 my ($func_name) = @_;
162              
163             # Parse --param key=value pairs
164 0         0 my %args;
165 0         0 for my $p (@params) {
166 0 0       0 if ($p =~ /^([^=]+)=(.*)$/) {
167 0         0 $args{$1} = $2;
168             } else {
169 0         0 die "Error: Invalid --param format '$p'. Use key=value\n";
170             }
171             }
172              
173 0         0 my $swaig_url = $base_url . '/swaig';
174              
175 0         0 my $payload = {
176             function => $func_name,
177             argument => {
178             parsed => [ \%args ],
179             },
180             };
181              
182 0         0 my $json_body = JSON::encode_json($payload);
183              
184 0 0       0 if ($verbose) {
185 0         0 print STDERR ">>> POST $swaig_url\n";
186 0         0 print STDERR ">>> Body: $json_body\n";
187             }
188              
189 0         0 my $response = http_post($swaig_url, $json_body);
190              
191 0 0       0 if ($verbose) {
192 0         0 print STDERR "<<< Status: $response->{status}\n";
193 0         0 print STDERR "<<< Body: $response->{content}\n";
194             }
195              
196 0 0       0 if ($raw) {
197 0         0 print $response->{content};
198 0 0       0 print "\n" unless $response->{content} =~ /\n$/;
199             } else {
200 0         0 my $data = eval { JSON::decode_json($response->{content}) };
  0         0  
201 0 0       0 if ($@) {
202 0         0 print $response->{content};
203 0 0       0 print "\n" unless $response->{content} =~ /\n$/;
204             } else {
205 0         0 print JSON->new->utf8->pretty->canonical->encode($data);
206             }
207             }
208             }
209              
210             # ============================================================
211             # HTTP helpers
212             # ============================================================
213              
214             sub _auth_headers {
215 0     0   0 my %headers;
216 0 0 0     0 if (defined $auth_user && defined $auth_pass) {
217 0         0 my $encoded = encode_base64("$auth_user:$auth_pass", '');
218 0         0 $headers{Authorization} = "Basic $encoded";
219             }
220 0         0 return %headers;
221             }
222              
223             sub http_get {
224 0     0   0 my ($target_url) = @_;
225 0         0 my %headers = _auth_headers();
226              
227 0 0       0 if ($verbose) {
228 0         0 print STDERR ">>> GET $target_url\n";
229             }
230              
231 0         0 my $response = $http->get($target_url, { headers => \%headers });
232              
233 0 0       0 if ($verbose) {
234 0         0 print STDERR "<<< Status: $response->{status}\n";
235             }
236              
237 0 0       0 unless ($response->{success}) {
238             die sprintf("Error: HTTP %s %s\n%s\n",
239 0   0     0 $response->{status}, $response->{reason}, $response->{content} // '');
240             }
241              
242 0         0 return $response;
243             }
244              
245             sub http_post {
246 0     0   0 my ($target_url, $body) = @_;
247 0         0 my %headers = _auth_headers();
248 0         0 $headers{'Content-Type'} = 'application/json';
249              
250 0         0 my $response = $http->post($target_url, {
251             headers => \%headers,
252             content => $body,
253             });
254              
255 0 0       0 unless ($response->{success}) {
256             die sprintf("Error: HTTP %s %s\n%s\n",
257 0   0     0 $response->{status}, $response->{reason}, $response->{content} // '');
258             }
259              
260 0         0 return $response;
261             }
262              
263             sub usage_exit {
264 3     3   9 my ($code) = @_;
265 3   50     13 $code //= 1;
266 3         59 print STDERR <<'USAGE';
267             swaig-test - CLI tool for testing SWAIG agent endpoints
268              
269             Usage:
270             swaig-test --url URL [OPTIONS]
271              
272             Options:
273             --url URL Agent URL with embedded auth (http://user:pass@host:port/route)
274             --dump-swml Fetch and display the SWML document
275             --list-tools List available SWAIG functions
276             --exec NAME Execute a SWAIG function by name
277             --param key=value Parameter for --exec (repeatable)
278             --raw Output compact JSON (no pretty-printing)
279             --verbose Show request/response details on stderr
280             --help, -h Show this help message
281              
282             Examples:
283             swaig-test --url http://user:pass@localhost:3000/ --dump-swml
284             swaig-test --url http://user:pass@localhost:3000/ --list-tools
285             swaig-test --url http://user:pass@localhost:3000/ --exec get_weather --param location=London
286             swaig-test --url http://user:pass@localhost:3000/ --exec get_weather --param location=London --raw
287             USAGE
288 3           exit $code;
289             }