| #!/usr/bin/env perl |
| use Mojolicious::Lite; |
| use Mojo::ByteStream 'b'; |
| use Mojo::Date; |
| use Mojo::JSON qw/true false encode_json decode_json/; |
| use strict; |
| use warnings; |
| use Mojo::JWT; |
| use Mojo::File qw/path/; |
| use Mojo::Util qw/slugify/; |
| |
| # This is an API fake server with fixtures |
| |
| my $secret = 's3cr3t'; |
| my $fixture_path = path(Mojo::File->new(__FILE__)->dirname)->child('..', 'fixtures'); |
| |
| our %tokens = ( |
| 'access_token' => "4dcf8784ccfd26fac9bdb82778fe60e2", |
| 'refresh_token' => "hlWci75xb8atDiq3924NUSvOdtAh7Nlf9z", |
| 'access_token_2' => "abcde", |
| 'access_token_3' => 'jvgjbvjgzucgdwuiKHJK', |
| 'refresh_token_2' => "fghijk", |
| 'new_client_id' => 'fCBbQkA2NDA3MzM1Yw==', |
| 'new_client_id_2' => 'hghGHhjhFRz_gJhjrd==', |
| 'new_client_secret' => 'KUMaFxs6R1WGud4HM22w3HbmYKHMnNHIiLJ2ihaWtB4N5JxGzZgyqs5GTLutrORj', |
| 'auth_token_1' => 'mscajfdghnjdfshtkjcuynxahgz5il' |
| ); |
| |
| helper get_token => sub { |
| my ($c, $token) = @_; |
| return $tokens{$token} |
| }; |
| |
| # Legacy: |
| helper jwt_encode => sub { |
| shift; |
| return Mojo::JWT->new( |
| secret => $secret, |
| token_type => 'api_token', |
| expires => time + (3 * 34 * 60 * 60), |
| claims => { @_ } |
| ); |
| }; |
| |
| # Legacy; |
| helper jwt_decode => sub { |
| my ($c, $auth) = @_; |
| $auth =~ s/\s*api_token\s+//; |
| return Mojo::JWT->new(secret => $secret)->decode($auth); |
| }; |
| |
| # Expiration helper |
| helper expired => sub { |
| my ($c, $auth, $set) = @_; |
| |
| $auth =~ s/^[^ ]+? //; |
| if ($set) { |
| $c->app->log->debug("Set $auth for expiration"); |
| $c->app->defaults('auth_' . $auth => 1); |
| return 1; |
| }; |
| |
| $c->app->log->debug("Check $auth for expiration: " . ( |
| $c->app->defaults('auth_' . $auth) // '0' |
| )); |
| |
| return $c->app->defaults('auth_' . $auth); |
| }; |
| |
| # Load fixture responses |
| helper 'load_response' => sub { |
| my $c = shift; |
| my $q_name = shift; |
| my $file = $fixture_path->child("response_$q_name.json"); |
| $c->app->log->debug("Load response from $file"); |
| |
| unless (-f $file) { |
| return { |
| status => 500, |
| json => { |
| errors => [[0, 'Unable to load query response from ' . $file]] |
| } |
| } |
| }; |
| |
| my $response = $file->slurp; |
| my $decode = decode_json($response); |
| unless ($decode) { |
| return { |
| status => 500, |
| json => { |
| errors => [[0, 'Unable to parse JSON']] |
| } |
| } |
| }; |
| |
| return $decode; |
| }; |
| |
| app->defaults('oauth.client_list' => []); |
| |
| |
| # Base page |
| get '/v1.0/' => sub { |
| shift->render(text => 'Fake server available'); |
| }; |
| |
| |
| get '/v1.0/redirect-target-a' => sub { |
| shift->render(text => 'Redirect Target!'); |
| } => 'redirect-target'; |
| |
| |
| # Base page |
| get '/v1.0/redirect' => sub { |
| my $c = shift; |
| $c->res->code(308); |
| $c->res->headers->location($c->url_for('redirect-target')->to_abs); |
| return $c->render(text => ''); |
| }; |
| |
| |
| # Search fixtures |
| get '/v1.0/search' => sub { |
| my $c = shift; |
| my $v = $c->validation; |
| $v->optional('q'); |
| $v->optional('page'); |
| $v->optional('ql'); |
| $v->optional('cq'); |
| $v->optional('count'); |
| $v->optional('context'); |
| $v->optional('offset'); |
| $v->optional('pipes'); |
| $v->optional('cutoff')->in(qw/true false/); |
| |
| $c->app->log->debug('Receive request'); |
| |
| # Response q=x&ql=cosmas3 |
| if ($v->param('ql') && $v->param('ql') eq 'cosmas3') { |
| return $c->render( |
| status => 400, |
| json => { |
| "\@context" => "http://korap.ids-mannheim.de/ns/koral/0.3/context.jsonld", |
| "errors" => [[307,"cosmas3 is not a supported query language!"]] |
| }); |
| }; |
| |
| if (!$v->param('q')) { |
| return $c->render(%{$c->load_response('query_no_query')}); |
| }; |
| |
| if ($v->param('q') eq 'error') { |
| return $c->render( |
| status => 500, |
| inline => '<html><head>ERROR</head></html>' |
| ); |
| }; |
| |
| my @slug_base = ($v->param('q')); |
| push @slug_base, 'o' . $v->param('offset') if defined $v->param('offset'); |
| push @slug_base, 'c' . $v->param('count') if defined $v->param('count'); |
| push @slug_base, 'co' . $v->param('cutoff') if defined $v->param('cutoff'); |
| push @slug_base, 'cq' if defined $v->param('cq'); |
| push @slug_base, 'p' . $v->param('pipes') if defined $v->param('pipes'); |
| |
| # Get response based on query parameter |
| my $response = $c->load_response('query_' . slugify(join('_', @slug_base))); |
| |
| # Check authentification |
| if (my $auth = $c->req->headers->header('Authorization')) { |
| |
| $c->app->log->debug("There is an authorization header $auth"); |
| my $jwt; |
| if ($auth =~ /^Bearer/) { |
| # Username unknown in OAuth2 |
| $response->{json}->{meta}->{authorized} = 'yes'; |
| } |
| elsif ($auth =~ /^api_token/ && ($jwt = $c->jwt_decode($auth))) { |
| $response->{json}->{meta}->{authorized} = $jwt->{username} if $jwt->{username}; |
| }; |
| |
| # Code is expired |
| if ($c->expired($auth)) { |
| |
| $c->app->log->debug("The access token has expired"); |
| |
| return $c->render( |
| status => 401, |
| json => { |
| errors => [[2003, 'Access token is expired']] |
| } |
| ); |
| } |
| |
| # Auth token is invalid |
| if ($auth =~ /^Bearer inv4lid/) { |
| $c->app->log->debug("The access token is invalid"); |
| |
| return $c->render( |
| status => 401, |
| json => { |
| errors => [[2011, 'Access token is invalid']] |
| } |
| ); |
| } |
| }; |
| |
| if ($v->param('pipes')) { |
| $response->{json}->{meta}->{pipes} = $v->param('pipes'); |
| }; |
| |
| # Set page parameter |
| if ($v->param('page')) { |
| $response->{json}->{meta}->{startIndex} = $v->param("startIndex"); |
| }; |
| |
| # Simple search fixture |
| $c->render(%$response); |
| |
| $c->app->log->debug('Rendered result'); |
| |
| return 1; |
| }; |
| |
| # Textinfo fixtures |
| get '/v1.0/corpus/:corpusId/:docId/:textId' => sub { |
| my $c = shift; |
| |
| my $file = join('_', ( |
| 'textinfo', |
| $c->stash('corpusId'), |
| $c->stash('docId'), |
| $c->stash('textId') |
| )); |
| |
| my $slug = slugify($file); |
| |
| # Get response based on query parameter |
| my $response = $c->load_response($slug); |
| return $c->render(%$response); |
| }; |
| |
| |
| # Matchinfo fixtures |
| get '/v1.0/corpus/:corpusId/:docId/:textId/:matchId/matchInfo' => sub { |
| my $c = shift; |
| |
| my $file = join('_', ( |
| 'matchinfo', |
| $c->stash('corpusId'), |
| $c->stash('docId'), |
| $c->stash('textId'), |
| $c->stash('matchId') |
| )); |
| |
| my $slug = slugify($file); |
| |
| # Get response based on query parameter |
| my $response = $c->load_response($slug); |
| return $c->render(%$response); |
| }; |
| |
| |
| # Statistics endpoint |
| get '/v1.0/statistics' => sub { |
| my $c = shift; |
| my $v = $c->validation; |
| $v->optional('cq'); |
| |
| my @list = 'corpusinfo'; |
| if ($v->param('cq')) { |
| push @list, $v->param('cq'); |
| }; |
| my $slug = slugify(join('_', @list)); |
| |
| # Get response based on query parameter |
| my $response = $c->load_response($slug); |
| return $c->render(%$response); |
| }; |
| |
| ############ |
| # Auth API # |
| ############ |
| |
| # Request API token |
| get '/v1.0/auth/logout' => sub { |
| my $c = shift; |
| |
| if (my $auth = $c->req->headers->header('Authorization')) { |
| |
| if ($auth =~ /^Bearer/) { |
| $c->app->log->debug('Server-Logout: ' . $auth); |
| return $c->render(json => { msg => [[0, 'Fine!']]}); |
| } |
| |
| elsif (my $jwt = $c->jwt_decode($auth)) { |
| my $user = $jwt->{username} if $jwt->{username}; |
| |
| $c->app->log->debug('Server-Logout: ' . $user); |
| return $c->render(json => { msg => [[0, 'Fine!']]}); |
| }; |
| }; |
| |
| return $c->render(status => 400, json => { error => [[0, 'No!']]}); |
| }; |
| |
| |
| # Request API token |
| get '/v1.0/auth/apiToken' => sub { |
| my $c = shift; |
| |
| # Get auth header |
| my $auth = $c->req->headers->authorization; |
| |
| # Authorization missing or not basic |
| if (!$auth || $auth !~ s/\s*Basic\s+//gi) { |
| return $c->render( |
| json => { |
| error => [[2, 'x']] |
| } |
| ); |
| }; |
| |
| # Decode header |
| my ($username, $pwd) = @{b($auth)->b64_decode->split(':')->to_array}; |
| |
| # the password is 'pass' |
| if ($pwd) { |
| |
| # the password is 'pass' |
| if ($pwd eq 'pass') { |
| |
| # Render info with token |
| my $jwt = $c->jwt_encode(username => $username); |
| |
| # Render in the Kustvakt fashion: |
| return $c->render( |
| format => 'html', |
| text => encode_json({ |
| %{$jwt->claims}, |
| expires => $jwt->expires, |
| token => $jwt->encode, |
| token_type => 'api_token' |
| }) |
| ); |
| } |
| |
| elsif ($pwd eq 'ldaperr') { |
| return $c->render( |
| format => 'html', |
| status => 401, |
| json => { |
| "errors" => [[2022,"LDAP Authentication failed due to unknown user or password!"]] |
| } |
| ); |
| }; |
| |
| return $c->render( |
| json => { |
| error => [[2004, undef]] |
| } |
| ); |
| }; |
| |
| return $c->render( |
| json => { |
| error => [[2004, undef]] |
| } |
| ); |
| }; |
| |
| |
| # Request API token |
| post '/v1.0/oauth2/token' => sub { |
| my $c = shift; |
| |
| my $grant_type = $c->param('grant_type') // 'undefined'; |
| |
| if ($grant_type eq 'password') { |
| |
| # Check for wrong client id |
| if ($c->param('client_id') ne '2') { |
| return $c->render( |
| json => { |
| "error_description" => "Unknown client with " . $_->{client_id}, |
| "error" => "invalid_client" |
| }, |
| status => 401 |
| ); |
| } |
| |
| # Check for wrong client secret |
| elsif ($c->param('client_secret') ne 'k414m4r-s3cr3t') { |
| return $c->render( |
| json => { |
| "error_description" => "Invalid client credentials", |
| "error" => "invalid_client" |
| }, |
| status => 401 |
| ); |
| } |
| |
| # Check for wrong user name |
| elsif ($c->param('username') !~ /^t.st$/) { |
| return $c->render(json => { |
| error => [[2004, undef]] |
| }); |
| } |
| |
| # Check for ldap error |
| elsif ($c->param('password') eq 'ldaperr') { |
| return $c->render( |
| format => 'html', |
| status => 401, |
| json => { |
| "errors" => [ |
| [ |
| 2022, |
| "LDAP Authentication failed due to unknown user or password!" |
| ] |
| ] |
| } |
| ); |
| } |
| |
| # Check for wrong password |
| elsif ($c->param('password') ne 'pass') { |
| return $c->render(json => { |
| format => 'html', |
| status => 401, |
| "errors" => [[2022,"LDAP Authentication failed due to unknown user or password!"]] |
| }); |
| } |
| |
| # Return fine access |
| return $c->render( |
| json => { |
| "access_token" => $c->get_token('access_token'), |
| "refresh_token" => $c->get_token('refresh_token'), |
| "scope" => "all", |
| "token_type" => "Bearer", |
| "expires_in" => 86400 |
| }); |
| } |
| |
| # Refresh token |
| elsif ($grant_type eq 'refresh_token') { |
| |
| if ($c->param('refresh_token') eq 'inv4lid') { |
| return $c->render( |
| status => 400, |
| json => { |
| "error_description" => "Refresh token is expired", |
| "error" => "invalid_grant" |
| } |
| ); |
| }; |
| |
| $c->app->log->debug("Refresh the token in the mock server!"); |
| |
| return $c->render( |
| status => 200, |
| json => { |
| "access_token" => $c->get_token("access_token_2"), |
| "refresh_token" => $c->get_token("refresh_token_2"), |
| "token_type" => "Bearer", |
| "expires_in" => 86400 |
| } |
| ); |
| } |
| |
| # Get auth_token_1 |
| elsif ($grant_type eq 'authorization_code') { |
| if ($c->param('code') eq $tokens{auth_token_1}) { |
| return $c->render( |
| status => 200, |
| json => { |
| "access_token" => $tokens{access_token_3}, |
| "expires_in" => 31536000, |
| "scope" => 'match_info search openid', |
| "token_type" => "Bearer" |
| } |
| ); |
| }; |
| } |
| |
| # Unknown token grant |
| else { |
| return $c->render( |
| status => 400, |
| json => { |
| "errors" => [ |
| [ |
| 0, "Grant Type unknown", $grant_type |
| ] |
| ] |
| } |
| ) |
| } |
| }; |
| |
| # Revoke API token |
| post '/v1.0/oauth2/revoke' => sub { |
| my $c = shift; |
| |
| my $refresh_token = $c->param('token'); |
| |
| if ($c->param('client_secret') ne 'k414m4r-s3cr3t') { |
| return $c->render( |
| json => { |
| "error_description" => "Invalid client credentials", |
| "error" => "invalid_client" |
| }, |
| status => 401 |
| ); |
| }; |
| |
| return $c->render( |
| text => '' |
| ) |
| }; |
| |
| # Register a client |
| post '/v1.0/oauth2/client/register' => sub { |
| my $c = shift; |
| my $json = $c->req->json; |
| |
| if ($json->{redirectURI}) { |
| return $c->render( |
| status => 400, |
| json => { |
| errors => [ |
| [ |
| 201, |
| "Unrecognized field \"redirectURI\" (class de.ids_mannheim.korap.web.input.OAuth2ClientJson), not marked as ignorable (5 known properties: \"redirect_uri\", \"type\", \"name\", \"description\", \"url\"])\n at [Source: (org.eclipse.jetty.server.HttpInputOverHTTP); line: 1, column: 94] (through reference chain: de.ids_mannheim.korap.web.input.OAuth2ClientJson[\"redirectURI\"])" |
| ] |
| ] |
| } |
| ); |
| }; |
| |
| my $name = $json->{name}; |
| my $desc = $json->{description}; |
| my $type = $json->{type}; |
| my $url = $json->{url}; |
| my $redirect_uri = $json->{redirect_uri}; |
| |
| my $list = $c->app->defaults('oauth.client_list'); |
| |
| push @$list, { |
| "client_id" => $tokens{new_client_id}, |
| "client_name" => $name, |
| "client_description" => $desc, |
| "client_url" => $url, |
| "client_redirect_uri" => $redirect_uri |
| }; |
| |
| if ($redirect_uri && $redirect_uri =~ /FAIL$/) { |
| return $c->render( |
| status => 400, |
| json => { |
| "error_description" => $redirect_uri . " is invalid.", |
| "error" => "invalid_request" |
| } |
| ) |
| }; |
| |
| # Confidential server application |
| if ($type eq 'CONFIDENTIAL') { |
| |
| return $c->render(json => { |
| client_id => $tokens{new_client_id_2}, |
| client_secret => $tokens{new_client_secret} |
| }); |
| }; |
| |
| # Desktop application |
| return $c->render(json => { |
| client_id => $tokens{new_client_id} |
| }); |
| }; |
| |
| |
| # Register a client |
| post '/v1.0/oauth2/client/list' => sub { |
| my $c = shift; |
| |
| my $v = $c->validation; |
| |
| $v->required('super_client_id'); |
| $v->required('super_client_secret'); |
| |
| if ($v->has_error) { |
| return $c->render( |
| json => [], |
| status => 400 |
| ); |
| }; |
| |
| # $c->param('client_secret'); |
| |
| # Is empty [] when nothing registered |
| |
| return $c->render( |
| json => $c->stash('oauth.client_list'), |
| status => 200 |
| ); |
| }; |
| |
| |
| # Get token list |
| post '/v1.0/oauth2/token/list' => sub { |
| my $c = shift; |
| return $c->render(json => [ |
| { |
| "client_description" => "Nur ein Beispiel", |
| "client_id" => $tokens{new_client_id}, |
| "client_name" => "Beispiel", |
| "client_url" => "", |
| "created_date" => "2021-04-14T19:40:26.742+02:00[Europe\/Berlin]", |
| "expires_in" => "31533851", |
| "scope" => [ |
| "match_info", |
| "search", |
| "openid" |
| ], |
| "token" => "jhkhkjhk_hjgjsfz67i", |
| "user_authentication_time" => "2021-04-14T19:39:41.81+02:00[Europe\/Berlin]" |
| } |
| ]); |
| }; |
| |
| del '/v1.0/oauth2/client/deregister/:client_id' => sub { |
| my $c = shift; |
| my $client_id = $c->stash('client_id'); |
| |
| my $list = $c->app->defaults('oauth.client_list'); |
| |
| my $break = -1; |
| for (my $i = 0; $i < @$list; $i++) { |
| if ($list->[$i]->{client_id} eq $client_id) { |
| $break = $i; |
| last; |
| }; |
| }; |
| |
| if ($break != -1) { |
| splice @$list, $break, 1; |
| } |
| |
| else { |
| return $c->render( |
| json => { |
| error_description => "Unknown client with $client_id.", |
| error => "invalid_client" |
| }, |
| status => 401 |
| ); |
| }; |
| |
| return $c->render( |
| json => $c->stash('oauth.client_list'), |
| status => 200 |
| ); |
| }; |
| |
| post '/v1.0/oauth2/authorize' => sub { |
| my $c = shift; |
| my $type = $c->param('response_type'); |
| my $client_id = $c->param('client_id'); |
| my $scope = $c->param('scope'); |
| my $state = $c->param('state'); |
| my $redirect_uri = $c->param('redirect_uri') // 'NO'; |
| |
| if ($type eq 'code' && $client_id eq 'xyz') { |
| |
| if ($state eq 'fail') { |
| $c->res->headers->location( |
| Mojo::URL->new($redirect_uri)->query({ |
| error_description => 'FAIL' |
| }) |
| ); |
| $c->res->code(400); |
| return $c->rendered; |
| }; |
| |
| return $c->redirect_to( |
| Mojo::URL->new($redirect_uri)->query({ |
| code => $tokens{auth_token_1}, |
| scope => $scope, |
| }) |
| ); |
| } |
| |
| elsif ($type eq 'code') { |
| |
| return $c->redirect_to( |
| Mojo::URL->new($redirect_uri)->query({ |
| code => $tokens{auth_token_1}, |
| scope => 'match_info search openid' |
| }) |
| ); |
| } |
| }; |
| |
| |
| ####################### |
| # Query Reference API # |
| ####################### |
| |
| use CHI; |
| my $chi = CHI->new( |
| driver => 'Memory', |
| global => 1 |
| ); |
| |
| # Store query |
| put '/v1.0/query/~:user/:query_name' => sub { |
| my $c = shift; |
| my $user = $c->stash('user'); |
| my $qname = $c->stash('query_name'); |
| |
| if ($chi->is_valid($qname)) { |
| return $c->render( |
| json => { |
| errors => [ |
| { |
| message => 'Unable to store query reference' |
| } |
| ] |
| }, status => 400 |
| ); |
| }; |
| |
| my $json = $c->req->json; |
| |
| my $store = { |
| name => $qname, |
| koralQuery => { '@type' => 'Okay' }, |
| query => $json->{query}, |
| queryType => $json->{queryType}, |
| type => $json->{type}, |
| queryLanguage => $json->{queryLanguage}, |
| }; |
| |
| if (exists $json->{description}) { |
| $store->{description} = $json->{description} |
| }; |
| |
| # Set query reference |
| $chi->set($qname => $store); |
| |
| my $queries = $chi->get('~queries') // []; |
| push @$queries, $qname; |
| $chi->set('~queries' => $queries); |
| |
| return $c->render( |
| status => 201, |
| text => '' |
| ); |
| }; |
| |
| # Get query |
| get '/v1.0/query/~:user/:query_name' => sub { |
| my $c = shift; |
| |
| my $user = $c->stash('user'); |
| my $qname = $c->stash('query_name'); |
| |
| my $json = $chi->get($qname); |
| |
| if ($json) { |
| return $c->render( |
| json => $json |
| ); |
| }; |
| |
| return $c->render( |
| json => { |
| errors => [ |
| { |
| message => 'Query reference not found' |
| } |
| ] |
| }, status => 404 |
| ); |
| }; |
| |
| |
| # Get all queries |
| get '/v1.0/query/~:user' => sub { |
| my $c = shift; |
| my $user = $c->stash('user'); |
| my $qs = $chi->get('~queries') // []; |
| my @queries = (); |
| foreach (@$qs) { |
| push @queries, $chi->get($_); |
| }; |
| return $c->render(json => { refs => \@queries }); |
| }; |
| |
| |
| # Store query |
| del '/v1.0/query/~:user/:query_name' => sub { |
| my $c = shift; |
| my $user = $c->stash('user'); |
| my $qname = $c->stash('query_name'); |
| |
| $chi->remove($qname); |
| |
| my $queries = $chi->get('~queries') // []; |
| |
| my @clean = (); |
| foreach (@$queries) { |
| push @clean, $_ unless $_ eq $qname |
| }; |
| |
| $chi->set('~queries' => \@clean); |
| |
| return $c->render( |
| status => 200, |
| text => '' |
| ); |
| }; |
| |
| post '/v1.0/oauth2/revoke/super' => sub { |
| my $c = shift; |
| |
| my $s_client_id = $c->param('super_client_id'); |
| my $s_client_secret = $c->param('super_client_secret'); |
| my $token = $c->param('token'); |
| |
| return $c->render(text => 'SUCCESS'); |
| }; |
| |
| get '/fakeclient/return' => sub { |
| my $c = shift; |
| $c->render( |
| text => 'welcome back! [' . $c->param('code') . ']' |
| ); |
| } => 'return_uri'; |
| |
| |
| app->start; |
| |
| |
| __END__ |
| |
| |
| # Temporary: |
| my $collection_query = { |
| '@type' => "koral:docGroup", |
| "operation" => "operation:or", |
| "operands" => [ |
| { |
| '@type' => "koral:docGroup", |
| "operation" => "operation:and", |
| "operands" => [ |
| { |
| '@type' => "koral:doc", |
| "key" => "title", |
| "match" => "match:eq", |
| "value" => "Der Birnbaum", |
| "type" => "type:string" |
| }, |
| { |
| '@type' => "koral:doc", |
| "key" => "pubPlace", |
| "match" => "match:eq", |
| "value" => "Mannheim", |
| "type" => "type:string" |
| }, |
| { |
| '@type' => "koral:docGroup", |
| "operation" => "operation:or", |
| "operands" => [ |
| { |
| '@type' => "koral:doc", |
| "key" => "subTitle", |
| "match" => "match:eq", |
| "value" => "Aufzucht oder Pflege", |
| "type" => "type:string" |
| }, |
| { |
| '@type' => "koral:doc", |
| "key" => "subTitle", |
| "match" => "match:eq", |
| "value" => "Gedichte", |
| "type" => "type:string" |
| } |
| ] |
| } |
| ] |
| }, |
| { |
| '@type' => "koral:doc", |
| "key" => "pubDate", |
| "match" => "match:geq", |
| "value" => "2015-03-05", |
| "type" => "type:date" |
| } |
| ] |
| }; |