use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected $query;
+ protected $request;
protected $fields;
+ protected $filterOperators = [
+ 'eq' => '=',
+ 'ne' => '!=',
+ 'gt' => '>',
+ 'lt' => '<',
+ 'gte' => '>=',
+ 'lte' => '<=',
+ 'like' => 'like'
+ ];
+
/**
* ListingResponseBuilder constructor.
*/
- public function __construct(Builder $query, array $fields)
+ public function __construct(Builder $query, Request $request, array $fields)
{
$this->query = $query;
+ $this->request = $request;
$this->fields = $fields;
}
*/
public function toResponse()
{
- $total = $this->query->count();
$data = $this->fetchData();
+ $total = $this->query->count();
return response()->json([
'data' => $data,
{
$this->applyCountAndOffset($this->query);
$this->applySorting($this->query);
- // TODO - Apply filtering
+ $this->applyFiltering($this->query);
return $this->query->get($this->fields);
}
+ /**
+ * Apply any filtering operations found in the request.
+ */
+ protected function applyFiltering(Builder $query)
+ {
+ $requestFilters = $this->request->get('filter', []);
+ if (!is_array($requestFilters)) {
+ return;
+ }
+
+ $queryFilters = collect($requestFilters)->map(function ($value, $key) {
+ return $this->requestFilterToQueryFilter($key, $value);
+ })->filter(function ($value) {
+ return !is_null($value);
+ })->values()->toArray();
+
+ $query->where($queryFilters);
+ }
+
+ /**
+ * Convert a request filter query key/value pair into a [field, op, value] where condition.
+ */
+ protected function requestFilterToQueryFilter($fieldKey, $value): ?array
+ {
+ $splitKey = explode(':', $fieldKey);
+ $field = $splitKey[0];
+ $filterOperator = $splitKey[1] ?? 'eq';
+
+ if (!in_array($field, $this->fields)) {
+ return null;
+ }
+
+ if (!in_array($filterOperator, array_keys($this->filterOperators))) {
+ $filterOperator = 'eq';
+ }
+
+ $queryOperator = $this->filterOperators[$filterOperator];
+ return [$field, $queryOperator, $value];
+ }
+
/**
* Apply sorting operations to the query from given parameters
* otherwise falling back to the first given field, ascending.
$defaultSortName = $this->fields[0];
$direction = 'asc';
- $sort = request()->get('sort', '');
+ $sort = $this->request->get('sort', '');
if (strpos($sort, '-') === 0) {
$direction = 'desc';
}
*/
protected function applyCountAndOffset(Builder $query)
{
- $offset = max(0, request()->get('offset', 0));
+ $offset = max(0, $this->request->get('offset', 0));
$maxCount = config('api.max_item_count');
- $count = request()->get('count', config('api.default_item_count'));
+ $count = $this->request->get('count', config('api.default_item_count'));
$count = max(min($maxCount, $count), 1);
$query->skip($offset)->take($count);
namespace BookStack\Exceptions;
-use Exception;
+class ApiAuthException extends UnauthorizedException {
-class ApiAuthException extends Exception
-{
-
- /**
- * ApiAuthException constructor.
- */
- public function __construct($message, $code = 401)
- {
- parent::__construct($message, $code);
- }
}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Exceptions;
+
+use Exception;
+
+class UnauthorizedException extends Exception
+{
+
+ /**
+ * ApiAuthException constructor.
+ */
+ public function __construct($message, $code = 401)
+ {
+ parent::__construct($message, $code);
+ }
+}
\ No newline at end of file
*/
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
{
- $listing = new ListingResponseBuilder($query, $fields);
+ $listing = new ListingResponseBuilder($query, request(), $fields);
return $listing->toResponse();
}
}
\ No newline at end of file
class BooksApiController extends ApiController
{
+ public $validation = [
+ 'create' => [
+ // TODO
+ ],
+ 'update' => [
+ // TODO
+ ],
+ ];
+
/**
* Get a listing of books visible to the user.
*/
'restricted', 'image_id',
]);
}
+
+ public function create()
+ {
+ // TODO -
+ }
+
+ public function read()
+ {
+ // TODO -
+ }
+
+ public function update()
+ {
+ // TODO -
+ }
+
+ public function delete()
+ {
+ // TODO -
+ }
}
\ No newline at end of file
namespace BookStack\Http\Middleware;
-use BookStack\Exceptions\ApiAuthException;
+use BookStack\Exceptions\UnauthorizedException;
use Closure;
use Illuminate\Http\Request;
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
+ {
+ // Validate the token and it's users API access
+ try {
+ $this->ensureAuthorizedBySessionOrToken();
+ } catch (UnauthorizedException $exception) {
+ return $this->unauthorisedResponse($exception->getMessage(), $exception->getCode());
+ }
+
+ return $next($request);
+ }
+
+ /**
+ * Ensure the current user can access authenticated API routes, either via existing session
+ * authentication or via API Token authentication.
+ * @throws UnauthorizedException
+ */
+ protected function ensureAuthorizedBySessionOrToken(): void
{
// Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser()) {
- if ($this->awaitingEmailConfirmation()) {
- return $this->emailConfirmationErrorResponse($request);
- }
- return $next($request);
+ $this->ensureEmailConfirmedIfRequested();
+ return;
}
// Set our api guard to be the default for this request lifecycle.
auth()->shouldUse('api');
// Validate the token and it's users API access
- try {
- auth()->authenticate();
- } catch (ApiAuthException $exception) {
- return $this->unauthorisedResponse($exception->getMessage(), $exception->getCode());
- }
-
- if ($this->awaitingEmailConfirmation()) {
- return $this->emailConfirmationErrorResponse($request, true);
- }
-
- return $next($request);
+ auth()->authenticate();
+ $this->ensureEmailConfirmedIfRequested();
}
/**
'code' => $code,
'message' => $message,
]
- ], 401);
+ ], $code);
}
}
return $next($request);
}
+
+ /**
+ * Provide an error response for when the current user's email is not confirmed
+ * in a system which requires it.
+ */
+ protected function emailConfirmationErrorResponse(Request $request)
+ {
+ if ($request->wantsJson()) {
+ return response()->json([
+ 'error' => [
+ 'code' => 401,
+ 'message' => trans('errors.email_confirmation_awaiting')
+ ]
+ ], 401);
+ }
+
+ return redirect('/register/confirm/awaiting');
+ }
}
namespace BookStack\Http\Middleware;
+use BookStack\Exceptions\UnauthorizedException;
use Illuminate\Http\Request;
trait ChecksForEmailConfirmation
{
+ /**
+ * Check if the current user has a confirmed email if the instance deems it as required.
+ * Throws if confirmation is required by the user.
+ * @throws UnauthorizedException
+ */
+ protected function ensureEmailConfirmedIfRequested()
+ {
+ if ($this->awaitingEmailConfirmation()) {
+ throw new UnauthorizedException(trans('errors.email_confirmation_awaiting'));
+ }
+ }
/**
* Check if email confirmation is required and the current user is awaiting confirmation.
return false;
}
-
- /**
- * Provide an error response for when the current user's email is not confirmed
- * in a system which requires it.
- */
- protected function emailConfirmationErrorResponse(Request $request, bool $forceJson = false)
- {
- if ($request->wantsJson() || $forceJson) {
- return response()->json([
- 'error' => [
- 'code' => 401,
- 'message' => trans('errors.email_confirmation_awaiting')
- ]
- ], 401);
- }
-
- return redirect('/register/confirm/awaiting');
- }
}
\ No newline at end of file
}
}
+ public function test_filter_parameter()
+ {
+ $this->actingAsApiEditor();
+ $book = Book::visible()->first();
+ $nameSubstr = substr($book->name, 0, 4);
+ $encodedNameSubstr = rawurlencode($nameSubstr);
+
+ $filterChecks = [
+ // Test different types of filter
+ "filter[id]={$book->id}" => 1,
+ "filter[id:ne]={$book->id}" => Book::visible()->where('id', '!=', $book->id)->count(),
+ "filter[id:gt]={$book->id}" => Book::visible()->where('id', '>', $book->id)->count(),
+ "filter[id:gte]={$book->id}" => Book::visible()->where('id', '>=', $book->id)->count(),
+ "filter[id:lt]={$book->id}" => Book::visible()->where('id', '<', $book->id)->count(),
+ "filter[name:like]={$encodedNameSubstr}%" => Book::visible()->where('name', 'like', $nameSubstr . '%')->count(),
+
+ // Test mulitple filters 'and' together
+ "filter[id]={$book->id}&filter[name]=random_non_existing_string" => 0,
+ ];
+
+ foreach ($filterChecks as $filterOption => $resultCount) {
+ $resp = $this->get($this->endpoint . '?count=1&' . $filterOption);
+ $resp->assertJson(['total' => $resultCount]);
+ }
+ }
+
}
\ No newline at end of file