From 743f43f8e9e65b0200ae5567234dd245a035935f Mon Sep 17 00:00:00 2001 From: vti Date: Tue, 2 May 2017 13:51:47 +0200 Subject: [PATCH] rest api --- README.md | 245 +++++++++++++++++- bin/crafty | 1 - data/config.yml.example | 2 - lib/Crafty.pm | 26 +- lib/Crafty/Action/API/Base.pm | 83 ++++++ lib/Crafty/Action/API/BuildLog.pm | 52 ++++ .../Action/{Tail.pm => API/BuildTail.pm} | 27 +- lib/Crafty/Action/API/CancelBuild.pm | 48 ++++ lib/Crafty/Action/API/CreateBuild.pm | 77 ++++++ lib/Crafty/Action/API/CreateEvent.pm | 22 ++ lib/Crafty/Action/API/GetBuild.pm | 32 +++ lib/Crafty/Action/API/ListBuilds.pm | 57 ++++ lib/Crafty/Action/API/RestartBuild.pm | 48 ++++ .../Action/{Events.pm => API/WatchEvents.pm} | 7 +- lib/Crafty/Action/Base.pm | 55 ---- lib/Crafty/Action/Build.pm | 32 +-- lib/Crafty/Action/Cancel.pm | 41 +-- lib/Crafty/Action/Download.pm | 51 +--- lib/Crafty/Action/Event.pm | 19 -- lib/Crafty/Action/Index.pm | 57 +--- lib/Crafty/Action/Restart.pm | 41 +-- lib/Crafty/DB.pm | 6 +- lib/Crafty/EventBus.pm | 84 ------ lib/Crafty/Log.pm | 17 +- lib/Crafty/PubSub.pm | 2 +- public/js/events.js | 3 +- public/js/main.js | 10 +- t/action/cancel.t | 65 ----- t/action/hook.t | 61 ----- t/action/tail.t | 54 ---- t/{action/download.t => api/build_log.t} | 16 +- t/api/build_tail.t | 29 +++ t/api/cancel_build.t | 65 +++++ t/api/create_build.t | 117 +++++++++ t/api/create_event.t | 29 +++ t/{action/index.t => api/list_builds.t} | 6 +- t/{action/restart.t => api/restart_build.t} | 14 +- t/app.t | 5 +- t/functional/api/list_builds.t | 73 ++++++ t/lib/TestSetup.pm | 5 +- t/pool.t | 38 +-- templates/build.caml | 14 +- templates/include/build.caml | 10 +- templates/index.caml | 4 +- 44 files changed, 1078 insertions(+), 672 deletions(-) create mode 100644 lib/Crafty/Action/API/Base.pm create mode 100644 lib/Crafty/Action/API/BuildLog.pm rename lib/Crafty/Action/{Tail.pm => API/BuildTail.pm} (69%) create mode 100644 lib/Crafty/Action/API/CancelBuild.pm create mode 100644 lib/Crafty/Action/API/CreateBuild.pm create mode 100644 lib/Crafty/Action/API/CreateEvent.pm create mode 100644 lib/Crafty/Action/API/GetBuild.pm create mode 100644 lib/Crafty/Action/API/ListBuilds.pm create mode 100644 lib/Crafty/Action/API/RestartBuild.pm rename lib/Crafty/Action/{Events.pm => API/WatchEvents.pm} (82%) delete mode 100644 lib/Crafty/Action/Base.pm delete mode 100644 lib/Crafty/Action/Event.pm delete mode 100644 lib/Crafty/EventBus.pm delete mode 100644 t/action/cancel.t delete mode 100644 t/action/hook.t delete mode 100644 t/action/tail.t rename t/{action/download.t => api/build_log.t} (75%) create mode 100644 t/api/build_tail.t create mode 100644 t/api/cancel_build.t create mode 100644 t/api/create_build.t create mode 100644 t/api/create_event.t rename t/{action/index.t => api/list_builds.t} (73%) rename t/{action/restart.t => api/restart_build.t} (74%) create mode 100644 t/functional/api/list_builds.t diff --git a/README.md b/README.md index 5a7baad..479a9ac 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Crafty is a dead simple but useful for personal projects CI server. - [x] dynamic workers (inproc, fork or detach mode) - [x] realtime updates - [x] realtime log tails -- [ ] REST API +- [x] REST API - [ ] webhook integration with GitHub, GitLab and BitBucket ## Configuration (YAML) @@ -71,6 +71,249 @@ You have to have *Perl* :camel: and *SQLite3* installed. $ bin/migrate $ bin/crafty +## REST API + +### Essentials + +#### Client Errors + +1. Invalid format + + HTTP/1.1 400 Bad Request + + {"error":"Invalid JSON"} + +2. Validation errors + + HTTP/1.1 422 Unprocessible Entity + + {"error":"Invalid fields","fields":{"project":"Required"}} + +#### Server Errors + +1. Something bad happened + + HTTP/1.1 500 System Error + + {"error":"Oops"} + +### Build Management + +#### List Builds + + GET /builds + +**Response** + + HTTP/1.1 200 Ok + Content-Type: application/json + + { + "builds": [{ + "status": "S", + "uuid": "d51ef218-2f1b-11e7-ab6d-4dcfdc676234", + "pid": 0, + "is_cancelable": "", + "created": "2017-05-02 11:43:44.430438+0200", + "finished": "2017-05-02 11:43:49.924477+0200", + "status_display": "success", + "is_new": "", + "branch": "master", + "project": "tu", + "is_restartable": "1", + "status_name": "Success", + "duration": 6.48342710037231, + "rev": "123", + "version": 4, + "message": "123", + "author": "vti", + "started": "2017-05-02 11:43:44.558950+0200" + }, ...] + "total": 5, + "pager": { + ... + } + } + +**Example** + + $ curl http://localhost:5000/api/builds + +#### Get Build + + GET /builds/:uuid + +**Response** + + HTTP/1.1 200 Ok + Content-Type: application/json + + { + "build" : + { + "status": "S", + "uuid": "d51ef218-2f1b-11e7-ab6d-4dcfdc676234", + "pid": 0, + "is_cancelable": "", + "created": "2017-05-02 11:43:44.430438+0200", + "finished": "2017-05-02 11:43:49.924477+0200", + "status_display": "success", + "is_new": "", + "branch": "master", + "project": "tu", + "is_restartable": "1", + "status_name": "Success", + "duration": 6.48342710037231, + "rev": "123", + "version": 4, + "message": "123", + "author": "vti", + "started": "2017-05-02 11:43:44.558950+0200" + } + } + +**Example** + + $ curl http://localhost:5000/api/builds + +#### Create Build + + POST /builds + +**Content type** + +Can be either `application/json` or `application/x-www-form-urlencoded`. + +**Body params** + +Required + +- project=[string] +- rev=[string] +- branch=[string] +- author=[string] +- message=[string] + +**Response** + + HTTP/1.1 200 Ok + Content-Type: application/json + + {"uuid":"d51ef218-2f1b-11e7-ab6d-4dcfdc676234"} + +**Example** + + $ curl http://localhost:5000/api/builds -d 'project=tu&rev=123&branch=master&author=vti&message=fix' + +#### Cancel Build + + POST /builds/:uuid/cancel + +**Response** + + HTTP/1.1 200 Ok + Content-Type: application/json + + {"ok":1} + +**Example** + + $ curl http://localhost:5000/api/builds/d51ef218-2f1b-11e7-ab6d-4dcfdc676234/cancel + +#### Restart Build + + POST /builds/:uuid/restart + +**Response** + + HTTP/1.1 200 Ok + Content-Type: application/json + + {"ok":1} + +**Example** + + $ curl http://localhost:5000/api/builds/d51ef218-2f1b-11e7-ab6d-4dcfdc676234/restart + +### Build Logs + +#### Download raw build log + + GET /builds/:uuid/log + +**Response** + + HTTP/1.0 200 OK + Content-Type: text/plain + Content-Disposition: attachment; filename=6b90cf28-2f12-11e7-b73a-e1bddc676234.log + + [...] + +**Example** + + $ curl http://localhost:5000/api/builds/d51ef218-2f1b-11e7-ab6d-4dcfdc676234/log + +#### Watching the build log + + GET /builds/:uuid/tail + +**Content Type** + +Output is in `text/event-stream` format. More info at +[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). + +**Response** + + HTTP/1.0 200 OK + Content-Type: text/event-stream; charset=UTF-8 + Access-Control-Allow-Methods: GET + Access-Control-Allow-Credentials: true + + data: [...] + +**Example** + + $ curl http://localhost:5000/api/builds/d51ef218-2f1b-11e7-ab6d-4dcfdc676234/tail + +### Events + +#### Watching events + + GET /events + +**Content Type** + +Output is in `text/event-stream` format. More info at +[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). + +**Response** + + HTTP/1.0 200 OK + Content-Type: text/event-stream; charset=UTF-8 + Access-Control-Allow-Methods: GET + Access-Control-Allow-Credentials: true + + data: [...] + +**Example** + + $ curl http://localhost:5000/api/events + +#### Create event + + POST /events + +**Response** + + HTTP/1.0 200 OK + Content-Type: application/json + + {"ok":1} + +**Example** + + $ curl http://localhost:5000/api/events -H 'Content-Type: application/json' -d '["event", {"data":"here"}]' + ## Troubleshooting Try *verbose* mode diff --git a/bin/crafty b/bin/crafty index 3e44d80..c00f680 100755 --- a/bin/crafty +++ b/bin/crafty @@ -24,7 +24,6 @@ use Getopt::Long; use Crafty; use Crafty::Log; use Crafty::Config; -use Crafty::EventBus; use Crafty::PubSub; my $opt_base = 'data'; diff --git a/data/config.yml.example b/data/config.yml.example index 8334382..b0b93e7 100644 --- a/data/config.yml.example +++ b/data/config.yml.example @@ -4,7 +4,5 @@ pool: mode: detach projects: - id: myapp - webhooks: - - provider: rest build: - sleep 5 diff --git a/lib/Crafty.pm b/lib/Crafty.pm index 7b6d8c3..7c37720 100644 --- a/lib/Crafty.pm +++ b/lib/Crafty.pm @@ -48,15 +48,21 @@ sub build_routes { my $routes = Routes::Tiny->new; - $routes->add_route('/', name => 'Index'); - $routes->add_route('/builds/:build_id', name => 'Build'); - $routes->add_route('/tail/:build_id', name => 'Tail'); - $routes->add_route('/cancel/:build_id', name => 'Cancel'); - $routes->add_route('/download/:build_id', name => 'Download'); - $routes->add_route('/restart/:build_id', name => 'Restart'); - - $routes->add_route('/events', name => 'Events'); - $routes->add_route('/_event', name => 'Event'); + $routes->add_route('/', method => 'GET', name => 'Index'); + $routes->add_route('/builds/:uuid', method => 'GET', name => 'Build'); + $routes->add_route('/cancel/:uuid', method => 'POST', name => 'Cancel'); + $routes->add_route('/download/:uuid', method => 'GET', name => 'Download'); + $routes->add_route('/restart/:uuid', method => 'POST', name => 'Restart'); + + $routes->add_route('/api/builds', method => 'POST', name => 'API::CreateBuild'); + $routes->add_route('/api/builds', method => 'GET', name => 'API::ListBuilds'); + $routes->add_route('/api/builds/:uuid', method => 'GET', name => 'API::GetBuild'); + $routes->add_route('/api/builds/:uuid/cancel', method => 'POST', name => 'API::CancelBuild'); + $routes->add_route('/api/builds/:uuid/restart', method => 'POST', name => 'API::RestartBuild'); + $routes->add_route('/api/builds/:uuid/tail', method => 'GET', name => 'API::BuildTail'); + $routes->add_route('/api/builds/:uuid/log', method => 'GET', name => 'API::BuildLog'); + $routes->add_route('/api/events', method => 'GET', name => 'API::WatchEvents'); + $routes->add_route('/api/events', method => 'POST', name => 'API::CreateEvent'); $routes->add_route('/webhook/:provider/:project', name => 'Hook'); @@ -74,7 +80,7 @@ sub to_psgi { my $path_info = $env->{PATH_INFO}; - my $match = $routes->match($path_info); + my $match = $routes->match($path_info, method => $env->{REQUEST_METHOD}); if ($match) { my $action = $self->_build_action( diff --git a/lib/Crafty/Action/API/Base.pm b/lib/Crafty/Action/API/Base.pm new file mode 100644 index 0000000..24d28e1 --- /dev/null +++ b/lib/Crafty/Action/API/Base.pm @@ -0,0 +1,83 @@ +package Crafty::Action::API::Base; +use Moo; + +use Plack::Request; +use JSON (); + +has 'config', is => 'ro', required => 1; +has 'env', is => 'ro', required => 1; +has 'db', is => 'ro', required => 1; +has 'pool', is => 'ro'; +has 'view', is => 'ro', required => 1; +has 'req', + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + + return Plack::Request->new($self->env); + }; + +sub content_type { 'application/json' } + +sub render { + my $self = shift; + my ($code, $body, $respond) = @_; + + my $headers = []; + + if ($self->content_type eq 'text/html') { + if ($body && ref $body && !$body->{ok}) { + my $template = lc((split /::/, ref($self))[-1]) . '.caml'; + + my $content = $self->view->render_file($template, $body); + $body = $self->view->render_file('layout.caml', { content => $content }); + } + } + elsif ($self->content_type eq 'application/json') { + push @$headers, 'Content-Type' => 'application/json'; + + $body = JSON::encode_json($body); + } + else { + die 'Unknown content type'; + } + + my $res = [ $code, $headers, [$body] ]; + + return $respond ? $respond->($res) : $res; +} + +sub not_found { + my $self = shift; + my ($respond) = @_; + + return $self->render(404, { error => 'Not Found' }, $respond); +} + +sub redirect { + my $self = shift; + my ($url, $respond) = @_; + + my $res = [ 302, [ Location => $url ], [''] ]; + + return $respond ? $respond->($res) : $res; +} + +sub handle_error { + my $self = shift; + my ($error, $respond) = @_; + + Crafty::Log->error(@_) unless ref $error; + + my $res; + eval { $res = ref $error ? $error : $self->render(500, { error => 'System error' }); } or do { + Crafty::Log->error($@); + + $res = [ 500, [], ['System error'] ]; + }; + + return $respond ? $respond->($res) : $res; +} + +1; diff --git a/lib/Crafty/Action/API/BuildLog.pm b/lib/Crafty/Action/API/BuildLog.pm new file mode 100644 index 0000000..7bad77d --- /dev/null +++ b/lib/Crafty/Action/API/BuildLog.pm @@ -0,0 +1,52 @@ +package Crafty::Action::API::BuildLog; +use Moo; +extends 'Crafty::Action::API::Base'; + +use HTTP::Date (); +use Promises qw(deferred); + +sub run { + my $self = shift; + my (%captures) = @_; + + my $uuid = $captures{uuid}; + + return sub { + my $respond = shift; + + $self->db->load($uuid)->then( + sub { + my ($build) = @_; + + my $stream = $self->config->catfile('builds_dir', $build->uuid . '.log'); + + open my $fh, '<:raw', $stream + or return deferred->reject($self->not_found($respond)); + + my @stat = stat $stream; + + return $respond->( + [ + 200, + [ + 'Content-Type' => 'text/plain', + 'Content-Length' => $stat[7], + 'Last-Modified' => HTTP::Date::time2str($stat[9]), + 'Content-Disposition' => "attachment; filename=$uuid.log" + ], + $fh + ] + ); + }, + sub { + return deferred->reject($self->not_found($respond)); + } + )->catch( + sub { + $self->handle_error(@_, $respond); + } + ); + }; +} + +1; diff --git a/lib/Crafty/Action/Tail.pm b/lib/Crafty/Action/API/BuildTail.pm similarity index 69% rename from lib/Crafty/Action/Tail.pm rename to lib/Crafty/Action/API/BuildTail.pm index 15fe7f2..2d7e5f3 100644 --- a/lib/Crafty/Action/Tail.pm +++ b/lib/Crafty/Action/API/BuildTail.pm @@ -1,6 +1,6 @@ -package Crafty::Action::Tail; +package Crafty::Action::API::BuildTail; use Moo; -extends 'Crafty::Action::Base'; +extends 'Crafty::Action::API::Base'; use JSON (); use AnyEvent; @@ -10,9 +10,9 @@ use Crafty::Log; sub run { my $self = shift; - my (%params) = @_; + my (%captures) = @_; - my $uuid = $params{build_id}; + my $uuid = $captures{uuid}; return sub { my $respond = shift; @@ -22,13 +22,11 @@ sub run { my ($build) = @_; my $cb = Plack::App::EventSource->new( - headers => ['Access-Control-Allow-Credentials', 'true'], + headers => [ 'Access-Control-Allow-Credentials', 'true' ], handler_cb => sub { my ($conn, $env) = @_; - my $stream = - $self->config->catfile('builds_dir', - $build->uuid . '.log'); + my $stream = $self->config->catfile('builds_dir', $build->uuid . '.log'); $self->tail($conn, $stream); } @@ -37,13 +35,13 @@ sub run { $cb->($respond); }, sub { - $respond->([404, [], ['Not found']]); + $respond->([ 404, [], ['Not found'] ]); } )->catch( sub { Crafty::Log->error(@_); - $respond->([500, [], ['error']]); + $respond->([ 500, [], ['error'] ]); } ); }; @@ -59,14 +57,12 @@ sub tail { $tail->tail( $path, on_error => sub { - warn 'error'; - $conn->push(JSON::encode_json({type => 'error'})); + $conn->push(JSON::encode_json(['tail.error'])); $conn->close; delete $connections->{"$conn"}; }, on_eof => sub { - warn 'eof'; - $conn->push(JSON::encode_json({type => 'eof'})); + $conn->push(JSON::encode_json(['tail.eof'])); $conn->close; delete $connections->{"$conn"}; }, @@ -75,8 +71,7 @@ sub tail { $content =~ s{\n}{\\n}g; - $conn->push( - JSON::encode_json({type => 'output', data => $content})); + $conn->push(JSON::encode_json([ 'tail.output', $content ])); } ); diff --git a/lib/Crafty/Action/API/CancelBuild.pm b/lib/Crafty/Action/API/CancelBuild.pm new file mode 100644 index 0000000..eb06a5f --- /dev/null +++ b/lib/Crafty/Action/API/CancelBuild.pm @@ -0,0 +1,48 @@ +package Crafty::Action::API::CancelBuild; +use Moo; +extends 'Crafty::Action::API::Base'; + +use Promises qw(deferred); +use Crafty::Build; + +sub run { + my $self = shift; + my (%captures) = @_; + + my $uuid = $captures{uuid}; + + return sub { + my $respond = shift; + + $self->db->load($uuid)->then( + sub { + my ($build) = @_; + + if (!$build->is_cancelable) { + return deferred->reject($self->render(400, { error => 'Build not cancelable' })); + } + else { + $build->cancel; + + return $self->db->save($build); + } + } + )->then( + sub { + my ($build) = @_; + + $self->pool->cancel($build); + + return $self->render(200, { ok => 1 }, $respond); + } + )->catch( + sub { + return $self->not_found($respond) unless @_; + + $self->handle_error(@_, $respond); + } + ); + }; +} + +1; diff --git a/lib/Crafty/Action/API/CreateBuild.pm b/lib/Crafty/Action/API/CreateBuild.pm new file mode 100644 index 0000000..12c398d --- /dev/null +++ b/lib/Crafty/Action/API/CreateBuild.pm @@ -0,0 +1,77 @@ +package Crafty::Action::API::CreateBuild; +use Moo; +extends 'Crafty::Action::API::Base'; + +use Input::Validator; +use Crafty::Build; + +sub build_validator { + my $self = shift; + + my $validator = Input::Validator->new(messages => { REQUIRED => 'Required' }); + + $validator->field('project')->required(1); + $validator->field('rev')->required(1); + $validator->field('branch')->required(1); + $validator->field('message')->required(1); + $validator->field('author')->required(1); + + return $validator; +} + +sub validate { + my $self = shift; + my ($validator, $params) = @_; + + return unless $validator->validate($params); + + my $values = $validator->values; + + my $project_config = $self->config->project($values->{project}); + unless ($project_config) { + $validator->error('project', 'Unknown project'); + return 0; + } + + return 1; +} + +sub run { + my $self = shift; + my (%captures) = @_; + + my $validator = $self->build_validator; + + my $content_type = $self->req->header('Content-Type') // ''; + + my $params = {}; + if ($content_type eq 'application/x-www-form-urlencoded') { + $params = $self->req->parameters; + } + elsif ($content_type eq 'application/json') { + eval { $params = JSON::decode_json($self->req->content); } or do { + return $self->render(400, { error => 'Invalid JSON' }); + }; + } + + return $self->render(422, { error => 'Invalid fields', fields => $validator->errors }) + unless $self->validate($validator, $params); + + return sub { + my $respond = shift; + + my $build = Crafty::Build->new(%$params); + + $build->init; + + $self->db->save($build)->then( + sub { + $self->pool->peek; + + $self->render(201, { uuid => $build->uuid }, $respond); + } + )->catch(sub { $self->handle_error(@_) }); + }; +} + +1; diff --git a/lib/Crafty/Action/API/CreateEvent.pm b/lib/Crafty/Action/API/CreateEvent.pm new file mode 100644 index 0000000..327c121 --- /dev/null +++ b/lib/Crafty/Action/API/CreateEvent.pm @@ -0,0 +1,22 @@ +package Crafty::Action::API::CreateEvent; +use Moo; +extends 'Crafty::Action::API::Base'; + +use JSON (); +use Crafty::PubSub; + +sub run { + my $self = shift; + + my $body = $self->req->content; + + eval { $body = JSON::decode_json($body); } or do { + return $self->render(400, { error => 'Invalid JSON' }); + }; + + Crafty::PubSub->instance->publish(@$body); + + return $self->render(200, { ok => 1 }); +} + +1; diff --git a/lib/Crafty/Action/API/GetBuild.pm b/lib/Crafty/Action/API/GetBuild.pm new file mode 100644 index 0000000..ef3d928 --- /dev/null +++ b/lib/Crafty/Action/API/GetBuild.pm @@ -0,0 +1,32 @@ +package Crafty::Action::API::GetBuild; +use Moo; +extends 'Crafty::Action::API::Base'; + +use Crafty::Build; + +sub run { + my $self = shift; + my (%captures) = @_; + + my $uuid = $captures{uuid}; + + return sub { + my $respond = shift; + + $self->db->load($uuid)->then( + sub { + my ($build) = @_; + + $self->render(200, { build => $build->to_hash }, $respond); + } + )->catch( + sub { + return $self->not_found($respond) unless @_; + + $self->handle_error(@_); + } + ); + }; +} + +1; diff --git a/lib/Crafty/Action/API/ListBuilds.pm b/lib/Crafty/Action/API/ListBuilds.pm new file mode 100644 index 0000000..6be28d1 --- /dev/null +++ b/lib/Crafty/Action/API/ListBuilds.pm @@ -0,0 +1,57 @@ +package Crafty::Action::API::ListBuilds; +use Moo; +extends 'Crafty::Action::API::Base'; + +use Crafty::Pager; +use Crafty::Build; + +sub run { + my $self = shift; + + my $current_page = $self->req->param('p'); + $current_page = 1 + unless $current_page && $current_page =~ m/^\d+$/ && $current_page > 0; + my $limit = 10; + + return sub { + my $respond = shift; + + my $total = 0; + $self->db->count->then( + sub { + ($total) = @_; + + return $self->db->find( + offset => ($current_page - 1) * $limit, + limit => $limit + ); + } + )->then( + sub { + my ($builds) = @_; + + my $pager = Crafty::Pager->new( + current_page => $current_page, + limit => $limit, + total => $total + )->pager; + + $self->render( + 200, + { + total => $total, + builds => [ map { $_->to_hash } @$builds ], + pager => $pager + }, + $respond + ); + } + )->catch( + sub { + $self->handle_error(@_); + } + ); + }; +} + +1; diff --git a/lib/Crafty/Action/API/RestartBuild.pm b/lib/Crafty/Action/API/RestartBuild.pm new file mode 100644 index 0000000..a01fc9e --- /dev/null +++ b/lib/Crafty/Action/API/RestartBuild.pm @@ -0,0 +1,48 @@ +package Crafty::Action::API::RestartBuild; +use Moo; +extends 'Crafty::Action::API::Base'; + +use Promises qw(deferred); +use Crafty::Build; + +sub run { + my $self = shift; + my (%captures) = @_; + + my $uuid = $captures{uuid}; + + return sub { + my $respond = shift; + + $self->db->load($uuid)->then( + sub { + my ($build) = @_; + + if (!$build->is_restartable) { + return deferred->reject($self->render(400, { error => 'Build not restartable' })); + } + else { + $build->restart; + + return $self->db->save($build); + } + } + )->then( + sub { + my ($build) = @_; + + $self->pool->peek; + + return $self->render(200, { ok => 1 }, $respond); + } + )->catch( + sub { + return $self->not_found($respond) unless @_; + + $self->handle_error(@_, $respond); + } + ); + }; +} + +1; diff --git a/lib/Crafty/Action/Events.pm b/lib/Crafty/Action/API/WatchEvents.pm similarity index 82% rename from lib/Crafty/Action/Events.pm rename to lib/Crafty/Action/API/WatchEvents.pm index 6ff544d..747a2d7 100644 --- a/lib/Crafty/Action/Events.pm +++ b/lib/Crafty/Action/API/WatchEvents.pm @@ -1,6 +1,6 @@ -package Crafty::Action::Events; +package Crafty::Action::API::WatchEvents; use Moo; -extends 'Crafty::Action::Base'; +extends 'Crafty::Action::API::Base'; use Promises qw(deferred); use JSON (); @@ -10,7 +10,6 @@ use Crafty::Log; sub run { my $self = shift; - my (%params) = @_; return sub { my $respond = shift; @@ -25,7 +24,7 @@ sub run { my ($ev, $data) = @_; eval { - $conn->push(JSON::encode_json({ type => $ev, data => $data })); + $conn->push(JSON::encode_json([ $ev, $data ])); 1; } or do { $conn->close; diff --git a/lib/Crafty/Action/Base.pm b/lib/Crafty/Action/Base.pm deleted file mode 100644 index 60d3ce5..0000000 --- a/lib/Crafty/Action/Base.pm +++ /dev/null @@ -1,55 +0,0 @@ -package Crafty::Action::Base; -use Moo; - -use Plack::Request; -use Crafty::Log; - -has 'config', is => 'ro', required => 1; -has 'env', is => 'ro', required => 1; -has 'db', is => 'ro', required => 1; -has 'view', is => 'ro', required => 1; -has 'pool', is => 'ro'; - -sub req { Plack::Request->new(shift->env) } - -sub render { - my $self = shift; - my ($template, $args) = @_; - - my $view = $self->{view}; - - my $content = $view->render_file($template, $args); - - return $view->render_file('layout.caml', { content => $content, verbose => Crafty::Log->is_verbose }); -} - -sub not_found { - my $self = shift; - my ($respond) = @_; - - my $res = [ '404', [], ['Not Found'] ]; - - return $respond ? $respond->($res) : $res; -} - -sub redirect { - my $self = shift; - my ($url, $respond) = @_; - - my $res = [ 302, [ Location => $url ], [''] ]; - - return $respond ? $respond->($res) : $res; -} - -sub handle_error { - my $self = shift; - my ($error, $respond) = @_; - - Crafty::Log->error(@_) unless ref $error; - - my $res = ref $error ? $error : [ 500, [], ['error'] ]; - - return $respond ? $respond->($res) : $res; -} - -1; diff --git a/lib/Crafty/Action/Build.pm b/lib/Crafty/Action/Build.pm index 865c160..48eb4a7 100644 --- a/lib/Crafty/Action/Build.pm +++ b/lib/Crafty/Action/Build.pm @@ -1,33 +1,7 @@ package Crafty::Action::Build; +use Moo; +extends 'Crafty::Action::API::GetBuild'; -use strict; -use warnings; - -use parent 'Crafty::Action::Base'; - -sub run { - my $self = shift; - my (%params) = @_; - - my $uuid = $params{build_id}; - - return sub { - my $respond = shift; - - $self->db->load($uuid)->then( - sub { - my ($build) = @_; - - my $content = - $self->render('build.caml', {build => $build->to_hash}); - - $respond->([200, [], [$content]]); - }, - sub { - $respond->([404, [], ['Not found']]); - } - ); - }; -} +sub content_type { 'text/html' } 1; diff --git a/lib/Crafty/Action/Cancel.pm b/lib/Crafty/Action/Cancel.pm index 8fcbd92..f9fecf3 100644 --- a/lib/Crafty/Action/Cancel.pm +++ b/lib/Crafty/Action/Cancel.pm @@ -1,44 +1,7 @@ package Crafty::Action::Cancel; use Moo; -extends 'Crafty::Action::Base'; +extends 'Crafty::Action::API::CancelBuild'; -use Promises qw(deferred); - -sub run { - my $self = shift; - my (%params) = @_; - - my $uuid = $params{build_id}; - - return sub { - my $respond = shift; - - $self->db->load($uuid)->then( - sub { - my ($build) = @_; - - return deferred->reject($self->not_found) - unless $build && $build->is_cancelable; - - return deferred->resolve($build); - }, - sub { - return deferred->reject($self->not_found); - } - )->then( - sub { - my ($build) = @_; - - $self->pool->cancel($build); - - return $self->redirect("/builds/$uuid", $respond); - }, - )->catch( - sub { - $self->handle_error(@_, $respond); - } - ); - }; -} +sub content_type { 'text/html' } 1; diff --git a/lib/Crafty/Action/Download.pm b/lib/Crafty/Action/Download.pm index 8a7c85e..599de7b 100644 --- a/lib/Crafty/Action/Download.pm +++ b/lib/Crafty/Action/Download.pm @@ -1,54 +1,5 @@ package Crafty::Action::Download; use Moo; -extends 'Crafty::Action::Base'; - -use HTTP::Date (); -use Promises qw(deferred); - -sub run { - my $self = shift; - my (%params) = @_; - - my $uuid = $params{build_id}; - - return sub { - my $respond = shift; - - $self->db->load($uuid)->then( - sub { - my ($build) = @_; - - my $stream = - $self->config->catfile('builds_dir', $build->uuid . '.log'); - - open my $fh, '<:raw', $stream - or return deferred->reject($self->not_found); - - my @stat = stat $stream; - - return $respond->( - [ - 200, - [ - 'Content-Type' => 'text/plain', - 'Content-Length' => $stat[7], - 'Last-Modified' => HTTP::Date::time2str($stat[9]), - 'Content-Disposition' => - "attachment; filename=$uuid.log" - ], - $fh - ] - ); - }, - sub { - return deferred->reject($self->not_found); - } - )->catch( - sub { - $self->handle_error(@_, $respond); - } - ); - }; -} +extends 'Crafty::Action::API::BuildLog'; 1; diff --git a/lib/Crafty/Action/Event.pm b/lib/Crafty/Action/Event.pm deleted file mode 100644 index 287ad00..0000000 --- a/lib/Crafty/Action/Event.pm +++ /dev/null @@ -1,19 +0,0 @@ -package Crafty::Action::Event; -use Moo; -extends 'Crafty::Action::Base'; - -use Crafty::PubSub; - -sub run { - my $self = shift; - - my $body = $self->req->content; - - $body = JSON::decode_json($body); - - Crafty::PubSub->instance->publish(@$body); - - return [ 200, [], ['ok'] ]; -} - -1; diff --git a/lib/Crafty/Action/Index.pm b/lib/Crafty/Action/Index.pm index 5f74e73..ad9cd0f 100644 --- a/lib/Crafty/Action/Index.pm +++ b/lib/Crafty/Action/Index.pm @@ -1,60 +1,7 @@ package Crafty::Action::Index; use Moo; -extends 'Crafty::Action::Base'; +extends 'Crafty::Action::API::ListBuilds'; -use Crafty::Log; -use Crafty::Pager; - -sub run { - my $self = shift; - - my $current_page = $self->req->param('p'); - $current_page = 1 - unless $current_page && $current_page =~ m/^\d+$/ && $current_page > 0; - my $limit = 10; - - return sub { - my $respond = shift; - - my $builds_count = 0; - $self->db->count->then( - sub { - ($builds_count) = @_; - - return $self->db->find( - offset => ($current_page - 1) * $limit, - limit => $limit - ); - } - )->then( - sub { - my ($builds) = @_; - - my $pager = Crafty::Pager->new( - current_page => $current_page, - limit => $limit, - total => $builds_count - )->pager; - - my $content = $self->render( - 'index.caml', - { - builds_count => $builds_count, - builds => [map { $_->to_hash } @$builds], - pager => $pager - } - ); - - $respond->([200, [], [$content]]); - } - )->catch( - sub { - Crafty::Log->error(@_); - - $respond->([500, [], ['error']]); - } - ); - }; -} +sub content_type { 'text/html' } 1; diff --git a/lib/Crafty/Action/Restart.pm b/lib/Crafty/Action/Restart.pm index ffda8a3..3ecc753 100644 --- a/lib/Crafty/Action/Restart.pm +++ b/lib/Crafty/Action/Restart.pm @@ -1,44 +1,7 @@ package Crafty::Action::Restart; use Moo; -extends 'Crafty::Action::Base'; +extends 'Crafty::Action::API::RestartBuild'; -use Promises qw(deferred); -use Crafty::Log; - -sub run { - my $self = shift; - my (%params) = @_; - - my $uuid = $params{build_id}; - - return sub { - my $respond = shift; - - $self->db->load($uuid)->then( - sub { - my ($build) = @_; - - if ($build && $build->restart) { - return $self->db->save($build); - } - else { - return deferred->reject($self->not_found); - } - }, - sub { - return deferred->reject($self->not_found); - } - )->then( - sub { - my ($build) = @_; - - $self->pool->peek; - - return $self->redirect(sprintf("/builds/%s", $build->uuid), - $respond); - } - )->catch(sub { $self->handle_error(@_, $respond) }); - }; -} +sub content_type { 'text/html' } 1; diff --git a/lib/Crafty/DB.pm b/lib/Crafty/DB.pm index e36d559..eadada8 100644 --- a/lib/Crafty/DB.pm +++ b/lib/Crafty/DB.pm @@ -46,7 +46,7 @@ sub save { $build->version(1); $build->not_new; - return $self->_broadcast('build.new', $build->to_hash)->then( + return $self->_broadcast('build.create', $build->to_hash)->then( sub { $deferred->resolve($build); } @@ -78,7 +78,7 @@ sub save { $build->version($build->version + 1); - $self->_broadcast('build', $build->to_hash)->then( + $self->_broadcast('build.update', $build->to_hash)->then( sub { return $deferred->resolve($build); } @@ -147,7 +147,7 @@ sub update_multi { foreach my $uuid (@$uuids) { $self->_broadcast( - 'build', + 'build.update', { uuid => $uuid, @set diff --git a/lib/Crafty/EventBus.pm b/lib/Crafty/EventBus.pm deleted file mode 100644 index 8365313..0000000 --- a/lib/Crafty/EventBus.pm +++ /dev/null @@ -1,84 +0,0 @@ -package Crafty::EventBus; - -use strict; -use warnings; - -use JSON (); -use AnyEvent; - -sub new { - my $class = shift; - - my $self = {}; - bless $self, $class; - - $self->{connections} = {}; - - return $self; -} - -our $INSTANCE; -sub instance { - my ($class) = @_; - - $INSTANCE ||= $class->new; - - return $INSTANCE; -} - -sub new_conn { - my $self = shift; - my ($conn, $env) = @_; - - my $connections = $self->{connections}; - - $connections->{"$conn"} = { - conn => $conn, - heartbeat => AnyEvent->timer( - interval => 30, - cb => sub { - eval { - $conn->push(''); - 1; - } or do { - delete $connections->{"$conn"}; - }; - } - ) - }; -} - -sub broadcast { - my $self = shift; - my ($event, $data) = @_; - - $data //= {}; - - $self->_broadcast({type => $event, data => $data}); -} - -sub _broadcast { - my $self = shift; - my ($event) = @_; - - $event = JSON::encode_json($event); - - my $connections = $self->{connections}; - - my @conn_keys = keys %$connections; - - foreach my $conn_key (@conn_keys) { - my $conn_info = $connections->{$conn_key}; - my $conn = $conn_info->{conn}; - - eval { - $conn->push($event); - 1; - } or do { - delete $connections->{$conn_key}; - $conn->close; - }; - } -} - -1; diff --git a/lib/Crafty/Log.pm b/lib/Crafty/Log.pm index 7e16fc8..06183d7 100644 --- a/lib/Crafty/Log.pm +++ b/lib/Crafty/Log.pm @@ -24,10 +24,7 @@ sub error { return if $QUIET; - $msg = sprintf($msg, @args); - $msg =~ s{\s+$}{}; - - warn "ERROR: " . $msg . "\n"; + warn "ERROR: " . $class->_format($msg, @args) . "\n"; } sub info { @@ -36,7 +33,17 @@ sub info { return unless $VERBOSE; - warn sprintf($msg, @args) . "\n"; + warn $class->_format($msg, @args) . "\n"; +} + +sub _format { + my $class = shift; + my ($msg, @args) = @_; + + $msg = @args ? sprintf($msg, @args) : $msg; + $msg =~ s{\s+$}{}; + + return $msg; } 1; diff --git a/lib/Crafty/PubSub.pm b/lib/Crafty/PubSub.pm index f1847c0..3b2974d 100644 --- a/lib/Crafty/PubSub.pm +++ b/lib/Crafty/PubSub.pm @@ -83,7 +83,7 @@ sub publish { elsif ($self->_host && $self->_port) { my $deferred = deferred; - my $url = sprintf 'http://%s:%s/_event', $self->_host, $self->_port; + my $url = sprintf 'http://%s:%s/api/events', $self->_host, $self->_port; my $body = JSON::encode_json([ $ev, $data ]); diff --git a/public/js/events.js b/public/js/events.js index fc7840e..3e0b86b 100644 --- a/public/js/events.js +++ b/public/js/events.js @@ -4,7 +4,8 @@ define(["/js/lib/EventSource.js"], function() { var listener = function (ev) { if (ev.type === "message") { if (ev.data) { - handler(jQuery.parseJSON(ev.data)); + var data = jQuery.parseJSON(ev.data); + handler(data[0], data[1]); } } else if (ev.type === "error") { diff --git a/public/js/main.js b/public/js/main.js index 6429562..7585c83 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -91,8 +91,8 @@ }); }); - events.connect('/events', function(data) { - eventBus.publish(data.type, data.data); + events.connect('/api/events', function(ev, data) { + eventBus.publish(ev, data); }); $('.console').each(function(index, el) { @@ -100,9 +100,9 @@ $(el).height($(window).height() - 250); - var es = events.connect('/tail/' + build, function(ev) { - if (ev.type == 'output') { - var data = ev.data.replace(/\\n/g, "\n"); + var es = events.connect('/api/builds/' + build + '/tail', function(ev, data) { + if (ev == 'tail.output') { + data = data.replace(/\\n/g, "\n"); $(el).append(data); } else { es.close(); diff --git a/t/action/cancel.t b/t/action/cancel.t deleted file mode 100644 index 9467a04..0000000 --- a/t/action/cancel.t +++ /dev/null @@ -1,65 +0,0 @@ -use strict; -use warnings; -use lib 't/lib'; - -use Test::More; -use Test::Deep; -use TestSetup; - -use AnyEvent; - -use_ok 'Crafty::Action::Cancel'; - -subtest 'error when build not found' => sub { - my $action = _build(env => {}); - - my $cb = $action->run(build_id => '123'); - - my $cv = AnyEvent->condvar; - - $cb->(sub { $cv->send(@_) }); - - my ($res) = $cv->recv; - - is $res->[0], 404; -}; - -subtest 'error when build not cancelable' => sub { - my $action = _build(env => {}); - - my $build = TestSetup->create_build(status => 'S'); - - my $cb = $action->run(build_id => $build->uuid); - - my $cv = AnyEvent->condvar; - - $cb->(sub { $cv->send(@_) }); - - my ($res) = $cv->recv; - - is $res->[0], 404; -}; - -#subtest 'redirects' => sub { -# my $action = _build(env => {}); -# -# my $build = TestSetup->create_build(); -# -# my $cb = $action->run(build_id => $build->uuid); -# -# my $cv = AnyEvent->condvar; -# -# $cb->(sub { $cv->send(@_) }); -# -# my ($res) = $cv->recv; -# -# is $res->[0], 302; -# -# $build = TestSetup->load_build($build->uuid); -# -# is $build->status, 'I'; -#}; - -done_testing; - -sub _build { TestSetup->build_action('Cancel', @_) } diff --git a/t/action/hook.t b/t/action/hook.t deleted file mode 100644 index 2d6c0a3..0000000 --- a/t/action/hook.t +++ /dev/null @@ -1,61 +0,0 @@ -use strict; -use warnings; -use lib 't/lib'; - -use Test::More; -use TestSetup; - -use_ok 'Crafty::Action::Hook'; - -subtest 'error on unknown provider' => sub { - my $action = _build(env => {}); - - my $res = $action->run(provider => 'unknown', project => 'my_project'); - - is $res->[0], 404; -}; - -subtest 'error on unknown project' => sub { - my $action = _build(env => {}); - - my $res = $action->run(provider => 'rest', project => 'unknown'); - - is $res->[0], 404; -}; - -subtest 'error when invalid params' => sub { - my $action = _build(env => {}); - - my $res = $action->run(provider => 'rest', project => 'my_project'); - - is $res->[0], 400; -}; - -subtest 'creates build' => sub { - my $action = _build( - env => { - QUERY_STRING => 'rev=123&branch=master&message=fix&author=vti' - } - ); - - my $cv = AnyEvent->condvar; - - my $cb = $action->run(provider => 'rest', project => 'my_project'); - - $cb->(sub { $cv->send(@_) }); - - my ($res) = $cv->recv; - - is $res->[0], 200; - - my $uuid = $res->[2]->[0]; - - my $build = TestSetup->load_build($uuid); - - is $build->status, 'I'; - like $build->created, qr/^\d{4}-/; -}; - -done_testing; - -sub _build { TestSetup->build_action('Hook', @_) } diff --git a/t/action/tail.t b/t/action/tail.t deleted file mode 100644 index a82846e..0000000 --- a/t/action/tail.t +++ /dev/null @@ -1,54 +0,0 @@ -use strict; -use warnings; -use lib 't/lib'; - -use Test::More; -use Test::Deep; -use TestSetup; - -use AnyEvent; - -use_ok 'Crafty::Action::Tail'; - -subtest 'error when build not found' => sub { - my $action = _build(env => {}); - - my $cb = $action->run(build_id => '123'); - - my $cv = AnyEvent->condvar; - - $cb->(sub { $cv->send(@_) }); - - my ($res) = $cv->recv; - - is $res->[0], 404; -}; - -#subtest 'creates build' => sub { -# my $action = _build( -# env => { -# QUERY_STRING => 'rev=123&branch=master&message=fix&author=vti' -# } -# ); -# -# my $cb = $action->run(provider => 'rest', project => 'my_project'); -# -# my $cv = AnyEvent->condvar; -# -# my $res; -# $cb->( -# sub { -# ($res) = @_; -# -# $cv->send; -# } -# ); -# -# $cv->recv; -# -# is $res->[0], 200; -#}; -# -done_testing; - -sub _build { TestSetup->build_action('Tail', @_) } diff --git a/t/action/download.t b/t/api/build_log.t similarity index 75% rename from t/action/download.t rename to t/api/build_log.t index d81a49c..9b6ff8e 100644 --- a/t/action/download.t +++ b/t/api/build_log.t @@ -8,12 +8,12 @@ use TestSetup; use AnyEvent; -use_ok 'Crafty::Action::Download'; +use_ok 'Crafty::Action::API::BuildLog'; subtest 'error when build not found' => sub { my $action = _build(env => {}); - my $cb = $action->run(build_id => '123'); + my $cb = $action->run(uuid => '123'); my $cv = AnyEvent->condvar; @@ -29,7 +29,7 @@ subtest 'error when stream not found' => sub { my $build = TestSetup->create_build(); - my $cb = $action->run(build_id => $build->uuid); + my $cb = $action->run(uuid => $build->uuid); my $cv = AnyEvent->condvar; @@ -45,10 +45,9 @@ subtest 'downloads file' => sub { my $build = TestSetup->create_build(); - TestSetup->write_file(TestSetup->base . '/builds/' . $build->uuid . '.log', - 'hello'); + TestSetup->write_file(TestSetup->base . '/builds/' . $build->uuid . '.log', 'hello'); - my $cb = $action->run(build_id => $build->uuid); + my $cb = $action->run(uuid => $build->uuid); my $cv = AnyEvent->condvar; @@ -62,8 +61,7 @@ subtest 'downloads file' => sub { 'Content-Type' => 'text/plain', 'Content-Length' => 5, 'Last-Modified' => ignore(), - 'Content-Disposition' => 'attachment; filename=' - . $build->uuid . '.log' + 'Content-Disposition' => 'attachment; filename=' . $build->uuid . '.log' ]; my $fh = $res->[2]; is <$fh>, 'hello'; @@ -71,4 +69,4 @@ subtest 'downloads file' => sub { done_testing; -sub _build { TestSetup->build_action('Download', @_) } +sub _build { TestSetup->build_action('API::BuildLog', @_) } diff --git a/t/api/build_tail.t b/t/api/build_tail.t new file mode 100644 index 0000000..966a1ec --- /dev/null +++ b/t/api/build_tail.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; +use Test::Deep; +use TestSetup; + +use AnyEvent; + +use_ok 'Crafty::Action::API::BuildTail'; + +subtest 'error when build not found' => sub { + my $action = _build(env => {}); + + my $cb = $action->run(uuid => '123'); + + my $cv = AnyEvent->condvar; + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 404; +}; + +done_testing; + +sub _build { TestSetup->build_action('API::BuildTail', @_) } diff --git a/t/api/cancel_build.t b/t/api/cancel_build.t new file mode 100644 index 0000000..a412063 --- /dev/null +++ b/t/api/cancel_build.t @@ -0,0 +1,65 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; +use Test::Deep; +use TestSetup; + +use AnyEvent; + +use_ok 'Crafty::Action::API::CancelBuild'; + +subtest 'error when build not found' => sub { + my $action = _build(env => {}); + + my $cb = $action->run(uuid => '123'); + + my $cv = AnyEvent->condvar; + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 404; +}; + +subtest 'error when build not cancelable' => sub { + my $action = _build(env => {}); + + my $build = TestSetup->create_build(status => 'S'); + + my $cb = $action->run(uuid => $build->uuid); + + my $cv = AnyEvent->condvar; + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 400; +}; + +subtest 'returns ok' => sub { + my $action = _build(env => {}); + + my $build = TestSetup->create_build(); + + my $cb = $action->run(uuid => $build->uuid); + + my $cv = AnyEvent->condvar; + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 200; + + $build = TestSetup->load_build($build->uuid); + + is $build->status, 'C'; +}; + +done_testing; + +sub _build { TestSetup->build_action('API::CancelBuild', @_) } diff --git a/t/api/create_build.t b/t/api/create_build.t new file mode 100644 index 0000000..6fb18df --- /dev/null +++ b/t/api/create_build.t @@ -0,0 +1,117 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; +use TestSetup; + +use JSON (); +use HTTP::Request::Common; +use HTTP::Message::PSGI qw(req_to_psgi); + +use_ok 'Crafty::Action::API::CreateBuild'; + +subtest 'error on unknown provider' => sub { + my $action = _build(env => req_to_psgi POST('/' => {})); + + my $res = $action->run; + + is $res->[0], 422; + like $res->[2]->[0], qr/required/i; +}; + +subtest 'error on unknown project' => sub { + my $action = _build( + env => req_to_psgi POST( + '/' => { project => 'unknown', rev => '123', branch => 'master', author => 'vti', message => 'fix' } + ) + ); + + my $res = $action->run; + + is $res->[0], 422; + like $res->[2]->[0], qr/unknown project/i; +}; + +subtest 'creates build from form' => sub { + my $action = _build( + env => req_to_psgi POST( + '/' => { project => 'my_project', rev => '123', branch => 'master', author => 'vti', message => 'fix' } + ) + ); + + my $cv = AnyEvent->condvar; + + my $cb = $action->run; + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 201; + + my $uuid = JSON::decode_json($res->[2]->[0])->{uuid}; + + my $build = TestSetup->load_build($uuid); + + is $build->status, 'I'; + is $build->project, 'my_project'; + is $build->rev, '123'; + is $build->branch, 'master'; + is $build->author, 'vti'; + is $build->message, 'fix'; + like $build->created, qr/^\d{4}-/; +}; + +subtest 'error on invalid json' => sub { + my $action = _build( + env => req_to_psgi POST( + '/' => 'Content-Type' => 'application/json', + Content => 'abc' + ) + ); + + my $cv = AnyEvent->condvar; + + my $res = $action->run; + + is $res->[0], 400; + like $res->[2]->[0], qr/invalid json/i; +}; + +subtest 'creates build from json' => sub { + my $action = _build( + env => req_to_psgi POST( + '/' => 'Content-Type' => 'application/json', + Content => JSON::encode_json( + { project => 'my_project', rev => '123', branch => 'master', author => 'vti', message => 'fix' } + ) + ) + ); + + my $cv = AnyEvent->condvar; + + my $cb = $action->run; + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 201; + + my $uuid = JSON::decode_json($res->[2]->[0])->{uuid}; + + my $build = TestSetup->load_build($uuid); + + is $build->status, 'I'; + is $build->project, 'my_project'; + is $build->rev, '123'; + is $build->branch, 'master'; + is $build->author, 'vti'; + is $build->message, 'fix'; + like $build->created, qr/^\d{4}-/; +}; + +done_testing; + +sub _build { TestSetup->build_action('API::CreateBuild', env => {}, @_) } diff --git a/t/api/create_event.t b/t/api/create_event.t new file mode 100644 index 0000000..9b32db3 --- /dev/null +++ b/t/api/create_event.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; +use TestSetup; + +use JSON (); +use HTTP::Request::Common; +use HTTP::Message::PSGI qw(req_to_psgi); + +use_ok 'Crafty::Action::API::CreateEvent'; + +subtest 'error on invalid fields' => sub { + my $action = _build( + env => req_to_psgi POST( + '/' => 'Content-Type' => 'application/json', + Content => 'abc' + ) + ); + + my $res = $action->run; + + is $res->[0], 400; +}; + +done_testing; + +sub _build { TestSetup->build_action('API::CreateEvent', env => {}, @_) } diff --git a/t/action/index.t b/t/api/list_builds.t similarity index 73% rename from t/action/index.t rename to t/api/list_builds.t index d022310..1c68ed6 100644 --- a/t/action/index.t +++ b/t/api/list_builds.t @@ -8,7 +8,7 @@ use TestSetup; use AnyEvent; -use_ok 'Crafty::Action::Index'; +use_ok 'Crafty::Action::API::ListBuilds'; subtest 'index page' => sub { my $action = _build(env => {}); @@ -24,9 +24,9 @@ subtest 'index page' => sub { my ($res) = $cv->recv; is $res->[0], 200; - like $res->[2]->[0], qr/Crafty/; + like $res->[2]->[0], qr/uuid/; }; done_testing; -sub _build { TestSetup->build_action('Index', @_) } +sub _build { TestSetup->build_action('API::ListBuilds', @_) } diff --git a/t/action/restart.t b/t/api/restart_build.t similarity index 74% rename from t/action/restart.t rename to t/api/restart_build.t index f863ab2..5147232 100644 --- a/t/action/restart.t +++ b/t/api/restart_build.t @@ -8,12 +8,12 @@ use TestSetup; use AnyEvent; -use_ok 'Crafty::Action::Restart'; +use_ok 'Crafty::Action::API::RestartBuild'; subtest 'error when build not found' => sub { my $action = _build(env => {}); - my $cb = $action->run(build_id => '123'); + my $cb = $action->run(uuid => '123'); my $cv = AnyEvent->condvar; @@ -29,7 +29,7 @@ subtest 'error when build not restartable' => sub { my $build = TestSetup->create_build(status => 'N'); - my $cb = $action->run(build_id => $build->uuid); + my $cb = $action->run(uuid => $build->uuid); my $cv = AnyEvent->condvar; @@ -37,7 +37,7 @@ subtest 'error when build not restartable' => sub { my ($res) = $cv->recv; - is $res->[0], 404; + is $res->[0], 400; }; subtest 'redirects' => sub { @@ -45,7 +45,7 @@ subtest 'redirects' => sub { my $build = TestSetup->create_build(status => 'S'); - my $cb = $action->run(build_id => $build->uuid); + my $cb = $action->run(uuid => $build->uuid); my $cv = AnyEvent->condvar; @@ -53,7 +53,7 @@ subtest 'redirects' => sub { my ($res) = $cv->recv; - is $res->[0], 302; + is $res->[0], 200; $build = TestSetup->load_build($build->uuid); @@ -62,4 +62,4 @@ subtest 'redirects' => sub { done_testing; -sub _build { TestSetup->build_action('Restart', @_) } +sub _build { TestSetup->build_action('API::RestartBuild', @_) } diff --git a/t/app.t b/t/app.t index 30396cb..b3a6c85 100644 --- a/t/app.t +++ b/t/app.t @@ -14,7 +14,7 @@ subtest 'error on unknown path' => sub { my $psgi = $app->to_psgi; - my $res = $psgi->({PATH_INFO => '/unknown'}); + my $res = $psgi->({ PATH_INFO => '/unknown', REQUEST_METHOD => 'GET' }); is $res->[0], 404; }; @@ -24,7 +24,7 @@ subtest 'returns rendered page' => sub { my $psgi = $app->to_psgi; - my $cb = $psgi->({PATH_INFO => '/'}); + my $cb = $psgi->({ PATH_INFO => '/', REQUEST_METHOD => 'GET' }); my $cv = AnyEvent->condvar; @@ -49,4 +49,3 @@ sub _build { pool => TestSetup->mock_pool ); } - diff --git a/t/functional/api/list_builds.t b/t/functional/api/list_builds.t new file mode 100644 index 0000000..1333608 --- /dev/null +++ b/t/functional/api/list_builds.t @@ -0,0 +1,73 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; +use Test::Deep; +use TestSetup; + +use AnyEvent; +use JSON (); +use HTTP::Request::Common; +use HTTP::Message::PSGI qw(req_to_psgi); +use Crafty; + +subtest 'returns zero response when no builds' => sub { + _setup(); + + my $cv = AnyEvent->condvar; + + my $app = _build(); + + my $cb = $app->(req_to_psgi GET('/api/builds')); + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 200; + is_deeply JSON::decode_json($res->[2]->[0]), + { + builds => [], + total => 0, + pager => undef + }; +}; + +subtest 'returns builds' => sub { + _setup(); + + my $build = TestSetup->create_build; + + my $cv = AnyEvent->condvar; + + my $app = _build(); + + my $cb = $app->(req_to_psgi GET('/api/builds')); + + $cb->(sub { $cv->send(@_) }); + + my ($res) = $cv->recv; + + is $res->[0], 200; + cmp_deeply JSON::decode_json($res->[2]->[0]), + { + builds => [ superhashof({ uuid => $build->uuid }) ], + total => 1, + pager => undef + }; +}; + +done_testing; + +sub _setup { + TestSetup->cleanup_db; +} + +sub _build { + return Crafty->new( + config => TestSetup->build_config, + db => TestSetup->build_db, + pool => TestSetup->mock_pool + )->to_psgi; +} diff --git a/t/lib/TestSetup.pm b/t/lib/TestSetup.pm index 1017b05..aebb7ac 100644 --- a/t/lib/TestSetup.pm +++ b/t/lib/TestSetup.pm @@ -125,8 +125,9 @@ sub mock_pool { my $mock = Test::MonkeyMock->new; - $mock->mock(start => sub { }); - $mock->mock(peek => sub { }); + $mock->mock(start => sub { }); + $mock->mock(peek => sub { }); + $mock->mock(cancel => sub { }); return $mock; } diff --git a/t/pool.t b/t/pool.t index 8ef9a05..4aa81c0 100644 --- a/t/pool.t +++ b/t/pool.t @@ -21,7 +21,7 @@ subtest 'build: builds successfully' => sub { '*' => sub { my ($ev, $data) = @_; - if ($ev eq 'build' && $data->{status} eq 'S') { + if ($ev eq 'build.update' && $data->{status} eq 'S') { $cv->end; } @@ -53,7 +53,7 @@ subtest 'build: builds failure' => sub { '*' => sub { my ($ev, $data) = @_; - if ($ev eq 'build' && $data->{status} eq 'F') { + if ($ev eq 'build.update' && $data->{status} eq 'F') { $cv->end; } @@ -85,7 +85,7 @@ subtest 'build: builds killed' => sub { '*' => sub { my ($ev, $data) = @_; - if ($ev eq 'build' && $data->{status} eq 'K') { + if ($ev eq 'build.update' && $data->{status} eq 'K') { $cv->end; } @@ -136,38 +136,6 @@ projects: EOF } -sub _run_pool { - my (%params) = @_; - - my $cv = AnyEvent->condvar; - - $cv->begin; - - my $pool = _build( - config => _build_config($params{config}), - on_event => sub { - my ($ev, $uuid) = @_; - - if ($ev eq 'build.done') { - $cv->end; - } - if ($ev eq 'build.error') { - $cv->end; - } - } - ); - - $pool->start; - $pool->peek; - - $cv->recv; - - $cv->begin; - $pool->stop(sub { $cv->end }); - - $cv->recv; -} - sub _build { my (%params) = @_; diff --git a/templates/build.caml b/templates/build.caml index f2e9688..01fd501 100644 --- a/templates/build.caml +++ b/templates/build.caml @@ -6,28 +6,28 @@
{{build.status_name}}
@@ -39,7 +39,7 @@
@@ -60,7 +60,7 @@ @@ -40,7 +40,7 @@
@@ -25,7 +25,7 @@ - + {{#builds}} {{>include/build.caml}} {{/builds}}