podman/hack/swagger-check
Tom Deseyn 4ceed6eb2f Update swagger-check
Signed-off-by: Tom Deseyn <tom.deseyn@gmail.com>
2021-04-07 15:26:24 +02:00

355 lines
11 KiB
Perl
Executable file

#!/usr/bin/perl
#
# swagger-check - Look for inconsistencies between swagger and source code
#
package LibPod::SwaggerCheck;
use v5.14;
use strict;
use warnings;
use File::Find;
(our $ME = $0) =~ s|.*/||;
(our $VERSION = '$Revision: 1.7 $ ') =~ tr/[0-9].//cd;
# For debugging, show data structures using DumpTree($var)
#use Data::TreeDumper; $Data::TreeDumper::Displayaddress = 0;
###############################################################################
# BEGIN user-customizable section
our $Default_Dir = 'pkg/api/server';
# END user-customizable section
###############################################################################
###############################################################################
# BEGIN boilerplate args checking, usage messages
sub usage {
print <<"END_USAGE";
Usage: $ME [OPTIONS] DIRECTORY-TO-CHECK
$ME scans all .go files under the given DIRECTORY-TO-CHECK
(default: $Default_Dir), looking for lines of the form 'r.Handle(...)'
or 'r.HandleFunc(...)'. For each such line, we check for a preceding
swagger comment line and verify that the comment line matches the
declarations in the r.Handle() invocation.
For example, the following would be a correctly-matching pair of lines:
// swagger:operation GET /images/json compat getImages
r.Handle(VersionedPath("/images/json"), s.APIHandler(compat.GetImages)).Methods(http.MethodGet)
...because http.MethodGet matches GET in the comment, the endpoint
is /images/json in both cases, the APIHandler() says "compat" so
that's the swagger tag, and the swagger operation name is the
same as the APIHandler but with a lower-case first letter.
The following is an inconsistency as reported by this script:
pkg/api/server/register_info.go:
- // swagger:operation GET /info libpod libpodGetInfo
+ // ................. ... ..... compat
r.Handle(VersionedPath("/info"), s.APIHandler(compat.GetInfo)).Methods(http.MethodGet)
...because APIHandler() says 'compat' but the swagger comment
says 'libpod'.
OPTIONS:
-v, --verbose show verbose progress indicators
-n, --dry-run make no actual changes
--help display this message
--version display program name and version
END_USAGE
exit;
}
# Command-line options. Note that this operates directly on @ARGV !
our $debug = 0;
our $force = 0;
our $verbose = 0;
our $NOT = ''; # print "blahing the blah$NOT\n" if $debug
sub handle_opts {
use Getopt::Long;
GetOptions(
'debug!' => \$debug,
'dry-run|n!' => sub { $NOT = ' [NOT]' },
'force' => \$force,
'verbose|v' => \$verbose,
help => \&usage,
man => \&man,
version => sub { print "$ME version $VERSION\n"; exit 0 },
) or die "Try `$ME --help' for help\n";
}
# END boilerplate args checking, usage messages
###############################################################################
############################## CODE BEGINS HERE ###############################
my $exit_status = 0;
# The term is "modulino".
__PACKAGE__->main() unless caller();
# Main code.
sub main {
# Note that we operate directly on @ARGV, not on function parameters.
# This is deliberate: it's because Getopt::Long only operates on @ARGV
# and there's no clean way to make it use @_.
handle_opts(); # will set package globals
# Fetch command-line arguments. Barf if too many.
my $dir = shift(@ARGV) || $Default_Dir;
die "$ME: Too many arguments; try $ME --help\n" if @ARGV;
# Find and act upon all matching files
find { wanted => sub { finder(@_) }, no_chdir => 1 }, $dir;
exit $exit_status;
}
############
# finder # File::Find action - looks for 'r.Handle' or 'r.HandleFunc'
############
sub finder {
my $path = $File::Find::name;
return if $path =~ m|/\.|; # skip dotfiles
return unless $path =~ /\.go$/; # Only want .go files
print $path, "\n" if $debug;
# Read each .go file. Keep a running tally of all '// comment' lines;
# if we see a 'r.Handle()' or 'r.HandleFunc()' line, pass it + comments
# to analysis function.
open my $in, '<', $path
or die "$ME: Cannot read $path: $!\n";
my @comments;
while (my $line = <$in>) {
if ($line =~ m!^\s*//!) {
push @comments, $line;
}
else {
# Not a comment line. If it's an r.Handle*() one, process it.
if ($line =~ m!^\s*r\.Handle(Func)?\(!) {
handle_handle($path, $line, @comments)
or $exit_status = 1;
}
# Reset comments
@comments = ();
}
}
close $in;
}
###################
# handle_handle # Cross-check a 'r.Handle*' declaration against swagger
###################
#
# Returns false if swagger comment is inconsistent with function call,
# true if it matches or if there simply isn't a swagger comment.
#
sub handle_handle {
my $path = shift; # for error messages only
my $line = shift; # in: the r.Handle* line
my @comments = @_; # in: preceding comment lines
# Preserve the original line, so we can show it in comments
my $line_orig = $line;
# Strip off the 'r.Handle*(' and leading whitespace; preserve the latter
$line =~ s!^(\s*)r\.Handle(Func)?\(!!
or die "$ME: INTERNAL ERROR! Got '$line'!\n";
my $indent = $1;
# Some have VersionedPath, some don't. Doesn't seem to make a difference
# in terms of swagger, so let's just ignore it.
$line =~ s!^VersionedPath\(([^\)]+)\)!$1!;
$line =~ m!^"(/[^"]+)",!
or die "$ME: $path:$.: Cannot grok '$line'\n";
my $endpoint = $1;
# Some function declarations require an argument of the form '{name:.*}'
# but the swagger (which gets derived from the comments) should not
# include them. Normalize all such args to just '{name}'.
$endpoint =~ s/\{name:\.\*\}/\{name\}/;
# e.g. /auth, /containers/*/rename, /distribution, /monitor, /plugins
return 1 if $line =~ /\.UnsupportedHandler/;
#
# Determine the HTTP METHOD (GET, POST, DELETE, HEAD)
#
my $method;
if ($line =~ /generic.VersionHandler/) {
$method = 'GET';
}
elsif ($line =~ m!\.Methods\((.*)\)!) {
my $x = $1;
if ($x =~ /Method(Post|Get|Delete|Head)/) {
$method = uc $1;
}
elsif ($x =~ /\"(HEAD|GET|POST)"/) {
$method = $1;
}
else {
die "$ME: $path:$.: Cannot grok $x\n";
}
}
else {
warn "$ME: $path:$.: No Methods in '$line'\n";
return 1;
}
#
# Determine the SWAGGER TAG. Assume 'compat' unless we see libpod; but
# this can be overruled (see special case below)
#
my $tag = ($endpoint =~ /(libpod)/ ? $1 : 'compat');
#
# Determine the OPERATION. Done in a helper function because there
# are a lot of complicated special cases.
#
my $operation = operation_name($method, $endpoint);
# Special case: the following endpoints all get a custom tag
if ($endpoint =~ m!/(pods|manifests)/!) {
$tag = $1;
}
# Special case: anything related to 'events' gets a system tag
if ($endpoint =~ m!/events!) {
$tag = 'system';
}
state $previous_path; # Previous path name, to avoid dups
#
# Compare actual swagger comment to what we expect based on Handle call.
#
my $expect = " // swagger:operation $method $endpoint $tag $operation ";
my @actual = grep { /swagger:operation/ } @comments;
return 1 if !@actual; # No swagger comment in file; oh well
my $actual = $actual[0];
# (Ignore whitespace discrepancies)
(my $a_trimmed = $actual) =~ s/\s+/ /g;
return 1 if $a_trimmed eq $expect;
# Mismatch. Display it. Start with filename, if different from previous
print "\n";
if (!$previous_path || $previous_path ne $path) {
print $path, ":\n";
}
$previous_path = $path;
# Show the actual line, prefixed with '-' ...
print "- $actual[0]";
# ...then our generated ones, but use '...' as a way to ignore matches
print "+ $indent//";
my @actual_split = split ' ', $actual;
my @expect_split = split ' ', $expect;
for my $i (1 .. $#actual_split) {
print " ";
if ($actual_split[$i] eq ($expect_split[$i]||'')) {
print "." x length($actual_split[$i]);
}
else {
# Show the difference. Use terminal highlights if available.
print "\e[1;37m" if -t *STDOUT;
print $expect_split[$i];
print "\e[m" if -t *STDOUT;
}
}
print "\n";
# Show the r.Handle* code line itself
print " ", $line_orig;
return;
}
####################
# operation_name # Given a method + endpoint, return the swagger operation
####################
sub operation_name {
my ($method, $endpoint) = @_;
# /libpod/foo/bar -> (libpod, foo, bar)
my @endpoints = grep { /\S/ } split '/', $endpoint;
# /libpod endpoints -> add 'Libpod' to end, e.g. PodStatsLibpod
my $Libpod = '';
my $main = shift(@endpoints);
if ($main eq 'libpod') {
$Libpod = ucfirst($main);
$main = shift(@endpoints);
}
$main =~ s/s$//; # e.g. Volumes -> Volume
# Next path component is an optional action:
# GET /containers/json -> ContainerList
# DELETE /libpod/containers/{name} -> ContainerDelete
# GET /libpod/containers/{name}/logs -> ContainerLogsLibpod
my $action = shift(@endpoints) || 'list';
$action = 'list' if $action eq 'json';
$action = 'delete' if $method eq 'DELETE';
# Anything with {id}, {name}, {name:..} may have a following component
if ($action =~ m!\{.*\}!) {
$action = shift(@endpoints) || 'inspect';
$action = 'inspect' if $action eq 'json';
}
# All sorts of special cases
if ($action eq 'df') {
$action = 'dataUsage';
}
# Grrrrrr, this one is annoying: some operations get an extra 'All'
elsif ($action =~ /^(delete|get|stats)$/ && $endpoint !~ /\{/) {
$action .= "All";
$main .= 's' if $main eq 'container';
}
# No real way to used MixedCase in an endpoint, so we have to hack it here
elsif ($action eq 'showmounted') {
$action = 'showMounted';
}
# Ping is a special endpoint, and even if /libpod/_ping, no 'Libpod'
elsif ($main eq '_ping') {
$main = 'system';
$action = 'ping';
$Libpod = '';
}
# Top-level compat endpoints
elsif ($main =~ /^(build|commit)$/) {
$main = 'image';
$action = $1;
}
# Top-level system endpoints
elsif ($main =~ /^(auth|event|info|version)$/) {
$main = 'system';
$action = $1;
$action .= 's' if $action eq 'event';
}
return "\u${main}\u${action}$Libpod";
}
1;