diff --git a/routes/api.php b/routes/api.php index 8a54b7509..ed6150abb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,3 +1,7 @@ config->get('auth.providers.users.model'); - if (false === $this->provider()) { + if (false === $this->hasProvider()) { return; } - if (false === $this->passportable($userClass)) { + if (false === $this->isPassportable($userClass)) { return; } - if (false === $this->passportClient()) { + if (false === $this->hasPassportClient()) { return; } @@ -87,7 +87,7 @@ public function handle() * @param $userClass * @return bool */ - public function passportable($userClass = null): bool + public function isPassportable($userClass = null): bool { try { $userInstance = $this->container->get($userClass); @@ -116,7 +116,7 @@ public function passportable($userClass = null): bool /** * @return bool */ - public function provider(): bool + public function hasProvider(): bool { $provider = $this->app->getProviders('Laravel\\Passport\\PassportServiceProvider'); @@ -132,7 +132,7 @@ public function provider(): bool /** * @return bool */ - public function passportClient(): bool + public function hasPassportClient(): bool { try { /** diff --git a/src/Commands/stubs/RestifyServiceProvider.stub b/src/Commands/stubs/RestifyServiceProvider.stub index 11d9fe7ac..4d73bb812 100644 --- a/src/Commands/stubs/RestifyServiceProvider.stub +++ b/src/Commands/stubs/RestifyServiceProvider.stub @@ -4,6 +4,7 @@ namespace App\Providers; use Illuminate\Support\Facades\Gate; use Binaryk\LaravelRestify\Restify; +use Illuminate\Http\Resources\Json\Resource; use Binaryk\LaravelRestify\RestifyApplicationServiceProvider; class RestifyServiceProvider extends RestifyApplicationServiceProvider @@ -16,6 +17,8 @@ class RestifyServiceProvider extends RestifyApplicationServiceProvider public function boot() { parent::boot(); + + Resource::withoutWrapping(); } /** diff --git a/src/Commands/stubs/repository.stub b/src/Commands/stubs/repository.stub index d61bda223..c3e51b640 100644 --- a/src/Commands/stubs/repository.stub +++ b/src/Commands/stubs/repository.stub @@ -2,8 +2,10 @@ namespace DummyNamespace; -use Illuminate\Http\Request; +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; +use Illuminate\Contracts\Pagination\Paginator; class DummyClass extends Repository { @@ -13,4 +15,67 @@ class DummyClass extends Repository * @var string */ public static $model = 'DummyFullModel'; + + /** + * @param RestifyRequest $request + * @return array + */ + public function fields(RestifyRequest $request) + { + return [ + // Field::make('title')->storingRules('required')->messages([ + // 'required' => 'This field is required bro.', + // ]), + ]; + + } + + /** + * @param RestifyRequest $request + * @param Paginator $paginated + * @return \Illuminate\Http\JsonResponse + */ + public function index(RestifyRequest $request, Paginator $paginated) + { + return parent::index($request, $paginated); + } + + /** + * @param RestifyRequest $request + * @return \Illuminate\Http\JsonResponse + * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Throwable + */ + public function show(RestifyRequest $request) + { + return parent::show($request); + } + + /** + * @param RestifyRequest $request + * @return \Illuminate\Http\JsonResponse + */ + public function store(RestifyRequest $request) + { + return parent::store($request); + } + + /** + * @param RestifyRequest $request + * @param $model + * @return \Illuminate\Http\JsonResponse|void + */ + public function update(RestifyRequest $request, $model) + { + return parent::update($request, $model); + } + + /** + * @param RestifyRequest $request + * @return \Illuminate\Http\JsonResponse + */ + public function destroy(RestifyRequest $request) + { + return parent::destroy($request); + } } diff --git a/src/Contracts/RestifySearchable.php b/src/Contracts/RestifySearchable.php index 237127b60..9b232045e 100644 --- a/src/Contracts/RestifySearchable.php +++ b/src/Contracts/RestifySearchable.php @@ -2,8 +2,6 @@ namespace Binaryk\LaravelRestify\Contracts; -use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; - /** * @author Eduard Lupacescu */ @@ -15,13 +13,6 @@ interface RestifySearchable const MATCH_BOOL = 'bool'; const MATCH_INTEGER = 'integer'; - /** - * @param RestifyRequest $request - * @param array $fields - * @return array - */ - public function serializeForIndex(RestifyRequest $request, array $fields = []); - /** * @return array */ diff --git a/src/Controllers/RestController.php b/src/Controllers/RestController.php index 6f00bd83b..d775dd27a 100644 --- a/src/Controllers/RestController.php +++ b/src/Controllers/RestController.php @@ -9,7 +9,6 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Services\Search\SearchService; -use Binaryk\LaravelRestify\Traits\PerformsQueries; use Illuminate\Config\Repository as Config; use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Access\Gate; @@ -22,7 +21,9 @@ use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Password; +use Throwable; /** * Abstract Class RestController. @@ -35,7 +36,7 @@ */ abstract class RestController extends BaseController { - use AuthorizesRequests, DispatchesJobs, ValidatesRequests, PerformsQueries; + use AuthorizesRequests, DispatchesJobs, ValidatesRequests; /** * @var RestResponse @@ -132,30 +133,46 @@ protected function response($data = null, $status = 200, array $headers = []) * @return array * @throws BindingResolutionException * @throws InstanceOfException + * @throws Throwable */ public function search($modelClass, $filters = []) { - $results = SearchService::instance() - ->setPredefinedFilters($filters) - ->search($this->request(), $modelClass instanceof Repository ? $modelClass->model() : new $modelClass); + $paginator = $this->paginator($modelClass, $filters); - $results->tap(function ($query) { - static::indexQuery($this->request(), $query); - }); - - /** - * @var \Illuminate\Pagination\Paginator - */ - $paginator = $results->paginate($this->request()->get('perPage') ?? ($modelClass::$defaultPerPage ?? RestifySearchable::DEFAULT_PER_PAGE)); if ($modelClass instanceof Repository) { - $items = $paginator->getCollection()->mapInto(get_class($modelClass))->map->serializeForIndex($this->request()); + $items = $paginator->getCollection()->mapInto(get_class($modelClass))->map->toArray($this->request()); } else { - $items = $paginator->getCollection()->map->serializeForIndex($this->request()); + $items = $paginator->getCollection(); } - return array_merge($paginator->toArray(), [ + return [ + 'meta' => Arr::except($paginator->toArray(), ['data', 'next_page_url', 'last_page_url', 'first_page_url', 'prev_page_url', 'path']), + 'links' => Arr::only($paginator->toArray(), ['next_page_url', 'last_page_url', 'first_page_url', 'prev_page_url', 'path']), 'data' => $items, - ]); + ]; + } + + /** + * @param $modelClass + * @param array $filters + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + * @throws BindingResolutionException + * @throws InstanceOfException + * @throws Throwable + */ + public function paginator($modelClass, $filters = []) + { + $results = SearchService::instance() + ->setPredefinedFilters($filters) + ->search($this->request(), $modelClass instanceof Repository ? $modelClass->model() : new $modelClass); + + $results->tap(function ($query) use ($modelClass) { + if ($modelClass instanceof Repository) { + $modelClass::indexQuery($this->request(), $query); + } + }); + + return $results->paginate($this->request()->get('perPage') ?? ($modelClass::$defaultPerPage ?? RestifySearchable::DEFAULT_PER_PAGE)); } /** diff --git a/src/Controllers/RestResponse.php b/src/Controllers/RestResponse.php index e5020a3dc..f5c1d135e 100644 --- a/src/Controllers/RestResponse.php +++ b/src/Controllers/RestResponse.php @@ -2,8 +2,11 @@ namespace Binaryk\LaravelRestify\Controllers; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; +use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; /** @@ -32,6 +35,8 @@ class RestResponse 'file', 'stack', 'data', + 'meta', + 'links', 'errors', ]; @@ -41,8 +46,8 @@ class RestResponse const REST_RESPONSE_AUTH_CODE = 401; const REST_RESPONSE_REFRESH_CODE = 103; const REST_RESPONSE_CREATED_CODE = 201; - const REST_RESPONSE_UPDATED_CODE = 201; - const REST_RESPONSE_DELETED_CODE = 204; + const REST_RESPONSE_UPDATED_CODE = 200; + const REST_RESPONSE_DELETED_CODE = 204; // update or delete with success const REST_RESPONSE_BLANK_CODE = 204; const REST_RESPONSE_ERROR_CODE = 500; const REST_RESPONSE_INVALID_CODE = 400; @@ -63,23 +68,42 @@ class RestResponse protected $line; /** - * Attributes to be appended to response at root level. + * The value of the attributes key MUST be an object (an “attributes object”). + * Members of the attributes object (“attributes”) represent information + * about the resource object in which it’s defined. * * @var array */ - protected $attributes = []; + protected $attributes; + + /** + * Where specified, a meta member can be used to include non-standard meta-information. + * The value of each meta member MUST be an object (a “meta object”). + * @var array + */ + protected $meta; + + /** + * A links object containing links related to the resource. + * @var + */ + protected $links; + /** * @var string */ private $file; + /** * @var string|null */ private $stack; + /** * @var array|null */ - private $errors = []; + private $errors; + /** * @var array|null */ @@ -90,11 +114,27 @@ class RestResponse */ protected $headers; + /** + * @var string + */ + protected $type; + + /** + * Key of the newly created resource. + * @var + */ + protected $id; + /** + * Model related entities. + * @var + */ + protected $relationships; + /** * RestResponse constructor. - * @param mixed $content - * @param int $status - * @param array $headers + * @param mixed $content + * @param int $status + * @param array $headers */ public function __construct($content = null, $status = 200, array $headers = []) { @@ -106,7 +146,7 @@ public function __construct($content = null, $status = 200, array $headers = []) /** * Set response data. * - * @param mixed $data + * @param mixed $data * @return $this|mixed */ public function data($data = null) @@ -123,7 +163,7 @@ public function data($data = null) /** * Set response errors. * - * @param array $errors + * @param array $errors * @return $this|null */ public function errors(array $errors = null) @@ -140,7 +180,7 @@ public function errors(array $errors = null) /** * Add error to response errors. * - * @param mixed $message + * @param mixed $message * @return $this */ public function addError($message) @@ -157,7 +197,7 @@ public function addError($message) /** * Set response Http code. * - * @param int $code + * @param int $code * @return $this|int */ public function code($code = self::REST_RESPONSE_SUCCESS_CODE) @@ -174,7 +214,7 @@ public function code($code = self::REST_RESPONSE_SUCCESS_CODE) /** * Set response Http code. * - * @param int $line + * @param int $line * @return $this|int */ public function line($line = null) @@ -189,7 +229,7 @@ public function line($line = null) } /** - * @param string $file + * @param string $file * @return $this|int */ public function file(string $file = null) @@ -204,7 +244,7 @@ public function file(string $file = null) } /** - * @param string|null $stack + * @param string|null $stack * @return $this|int */ public function stack(string $stack = null) @@ -221,7 +261,7 @@ public function stack(string $stack = null) /** * Magic to get response code constants. * - * @param string $key + * @param string $key * @return mixed|null */ public function __get($key) @@ -257,7 +297,7 @@ public function __call($func, $args) /** * Build a new response with our response data. * - * @param mixed $response + * @param mixed $response * * @return JsonResponse * @throws \Illuminate\Contracts\Container\BindingResolutionException @@ -266,6 +306,7 @@ public function respond($response = null) { if (! func_num_args()) { $response = new \stdClass(); + $response->data = new \stdClass(); foreach ($this->fillable() as $property) { if (isset($this->{$property})) { @@ -273,24 +314,75 @@ public function respond($response = null) } } - foreach ($this->attributes as $attribute => $value) { - $response->{$attribute} = $value; + //according with https://jsonapi.org/format/#document-top-level these fields should be in data: + foreach (['attributes', 'relationships', 'type', 'id'] as $item) { + if (isset($this->{$item})) { + $response->data->{$item} = $this->{$item}; + } } } - return $this->response()->json($response, is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE, $this->headers); + return $this->response()->json(static::beforeRespond($response), is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE, $this->headers); + } + + /** + * Set a root meta on response object. + * + * @param $name + * @param $value + * @return $this + */ + public function setMeta($name, $value) + { + $this->meta[$name] = $value; + + return $this; + } + + /** + * Set a root meta on response object. + * + * @param $meta + * @return $this + */ + public function meta($meta) + { + if (func_num_args()) { + $this->meta = ($meta instanceof Arrayable) ? $meta->toArray() : $meta; + + return $this; + } + + return $this; + } + + /** + * Set a root meta on response object. + * + * @param $links + * @return $this + */ + public function links($links) + { + if (func_num_args()) { + $this->links = ($links instanceof Arrayable) ? $links->toArray() : $links; + + return $this; + } + + return $this; } /** - * Set a root attribute on response object. + * Set a root link on response object. * * @param $name * @param $value * @return $this */ - public function setAttribute($name, $value) + public function setLink($name, $value) { - $this->attributes[$name] = $value; + $this->links[$name] = $value; return $this; } @@ -302,7 +394,7 @@ public function setAttribute($name, $value) */ public function message($message) { - return $this->setAttribute('message', $message); + return $this->setMeta('message', $message); } /** @@ -316,6 +408,19 @@ public function getAttribute($name) return $this->attributes[$name]; } + /** + * Set attributes at root level. + * + * @param array $attributes + * @return mixed + */ + public function setAttributes(array $attributes) + { + $this->attributes = $attributes; + + return $this; + } + /** * @return array */ @@ -332,4 +437,79 @@ protected function response() { return app()->make(ResponseFactory::class); } + + /** + * @param $name + * @param $value + * @return RestResponse + */ + public function header($name, $value) + { + $this->headers[$name] = $value; + + return $this; + } + + /** + * @param $type + * @return $this + */ + public function type($type) + { + $this->type = $type; + + return $this; + } + + /** + * Useful when newly created repository, will prepare the response according + * with JSON:API https://jsonapi.org/format/#document-resource-object-fields. + * + * @param Repository $repository + * @param bool $withRelations + * @return $this + */ + public function forRepository(Repository $repository, $withRelations = false) + { + $model = $repository->model(); + + if (false === $model instanceof Model) { + return $this; + } + + if (is_null($model->getKey())) { + return $this; + } + $this->type($repository::uriKey()); + $this->setAttributes($model->attributesToArray()); + $this->id = $model->getKey(); + + if ($withRelations && $model instanceof RestifySearchable && $model::getWiths()) { + foreach ($model::getWiths() as $k => $relation) { + if ($model->relationLoaded($relation)) { + $this->relationships[$relation] = $model->{$relation}->get(); + } + } + } + + return $this; + } + + public static function beforeRespond($response) + { + //The members data and errors MUST NOT coexist in the same document. - https://jsonapi.org/format/#introduction + if (isset($response->errors)) { + unset($response->data); + + return $response; + } + + if (isset($response->data)) { + unset($response->errors); + + return $response; + } + + return $response; + } } diff --git a/src/Events/RestifyBeforeEach.php b/src/Events/RestifyBeforeEach.php index 815f061d2..6a714f513 100644 --- a/src/Events/RestifyBeforeEach.php +++ b/src/Events/RestifyBeforeEach.php @@ -16,7 +16,7 @@ class RestifyBeforeEach /** * RestifyAfterEach constructor. - * @param Request $request + * @param $request */ public function __construct($request) { diff --git a/src/Events/RestifyStarting.php b/src/Events/RestifyStarting.php index 76de09431..863fe207f 100644 --- a/src/Events/RestifyStarting.php +++ b/src/Events/RestifyStarting.php @@ -16,7 +16,7 @@ class RestifyStarting /** * RestifyServing constructor. - * @param Request $request + * @param $request */ public function __construct($request) { diff --git a/src/Controllers/RestIndexController.php b/src/Fields/BaseField.php similarity index 52% rename from src/Controllers/RestIndexController.php rename to src/Fields/BaseField.php index 108bef6a6..77e8e1c5a 100644 --- a/src/Controllers/RestIndexController.php +++ b/src/Fields/BaseField.php @@ -1,10 +1,10 @@ */ -trait RestIndexController +abstract class BaseField { } diff --git a/src/Fields/Field.php b/src/Fields/Field.php new file mode 100644 index 000000000..1a43c325e --- /dev/null +++ b/src/Fields/Field.php @@ -0,0 +1,203 @@ + + */ +class Field extends OrganicField implements JsonSerializable +{ + /** + * Column name of the field. + * @var string|callable|null + */ + public $attribute; + + /** + * Callback called when the value is filled, this callback will do not override the fill action. + * @var Closure + */ + public $storeCallback; + + /** + * Callback called when trying to fill this attribute, this callback will override the fill action, so make + * sure you assign the attribute to the model over this callback. + * + * @var Closure + */ + public $fillCallback; + + /** + * Create a new field. + * + * @param string|callable|null $attribute + */ + public function __construct($attribute) + { + $this->attribute = $attribute; + } + + /** + * Create a new element. + * + * @param array $arguments + * @return static + */ + public static function make(...$arguments) + { + return new static(...$arguments); + } + + /** + * {@inheritdoc} + */ + public function jsonSerialize() + { + return []; + } + + /** + * Callback called when the value is filled, this callback will do not override the fill action. If fillCallback is defined + * this will do not be called. + * + * @param Closure $callback + * @return Field + */ + public function storeCallback(Closure $callback) + { + $this->storeCallback = $callback; + + return $this; + } + + /** + * Callback called when trying to fill this attribute, this callback will override the fill action, so make + * sure you assign the attribute to the model over this callback. + * + * @param Closure $callback + * @return $this + */ + public function fillCallback(Closure $callback) + { + $this->fillCallback = $callback; + + return $this; + } + + /** + * Fill attribute with value from the request or delegate this action to the user defined callback. + * + * @param RestifyRequest $request + * @param $model + * @return mixed|void + */ + public function fillAttribute(RestifyRequest $request, $model) + { + if (isset($this->fillCallback)) { + return call_user_func( + $this->fillCallback, $request, $model, $this->attribute + ); + } + + return $this->fillAttributeFromRequest( + $request, $model, $this->attribute + ); + } + + /** + * Fill the model with value from the request. + * + * @param RestifyRequest $request + * @param $model + * @param $attribute + */ + protected function fillAttributeFromRequest(RestifyRequest $request, $model, $attribute) + { + if ($request->exists($attribute)) { + $value = $request[$attribute]; + + $model->{$attribute} = is_callable($this->storeCallback) ? call_user_func($this->storeCallback, $value, $request, $model) : $value; + } + } + + /** + * @return callable|string|null + */ + public function getAttribute() + { + return $this->attribute; + } + + /** + * Validation rules for store. + * @param callable|array|string $rules + * @return Field + */ + public function storingRules($rules) + { + $this->storingRules = ($rules instanceof Rule || is_string($rules)) ? func_get_args() : $rules; + + return $this; + } + + /** + * Validation rules for update. + * + * @param callable|array|string $rules + * @return Field + */ + public function updatingRules($rules) + { + $this->updatingRules = ($rules instanceof Rule || is_string($rules)) ? func_get_args() : $rules; + + return $this; + } + + /** + * Validation rules for store. + * @param callable|array|string $rules + * @return Field + */ + public function rules($rules) + { + $this->rules = ($rules instanceof Rule || is_string($rules)) ? func_get_args() : $rules; + + return $this; + } + + /** + * Validation messages. + * + * @param array $messages + * @return Field + */ + public function messages(array $messages) + { + $this->messages = $messages; + + return $this; + } + + /** + * Validation rules for storing. + * + * @return array + */ + public function getStoringRules() + { + return array_merge($this->rules, $this->storingRules); + } + + /** + * @return array + */ + public function getUpdatingRules() + { + return array_merge($this->rules, $this->updatingRules); + } +} diff --git a/src/Fields/OrganicField.php b/src/Fields/OrganicField.php new file mode 100644 index 000000000..cf86ee654 --- /dev/null +++ b/src/Fields/OrganicField.php @@ -0,0 +1,34 @@ + + */ +abstract class OrganicField extends BaseField +{ + /** + * Rules for applied when store. + * + * @var array + */ + public $storingRules = []; + + /** + * Rules for applied when update model. + * @var array + */ + public $updatingRules = []; + + /** + * Rules for applied when store and update. + * + * @var array + */ + public $rules = []; + + /** + * @var array + */ + public $messages = []; +} diff --git a/src/Http/Controllers/RepositoryDestroyController.php b/src/Http/Controllers/RepositoryDestroyController.php new file mode 100644 index 000000000..384f456de --- /dev/null +++ b/src/Http/Controllers/RepositoryDestroyController.php @@ -0,0 +1,39 @@ + + */ +class RepositoryDestroyController extends RepositoryController +{ + /** + * @param RepositoryDestroyRequest $request + * @return JsonResponse + * @throws AuthorizationException + * @throws BindingResolutionException + * @throws EntityNotFoundException + * @throws Throwable + * @throws UnauthorizedException + */ + public function handle(RepositoryDestroyRequest $request) + { + /** + * @var Repository + */ + $repository = $request->newRepository(); + + $repository->authorizeToDelete($request); + + return $repository->destroy($request); + } +} diff --git a/src/Http/Controllers/RepositoryIndexController.php b/src/Http/Controllers/RepositoryIndexController.php index 9303cf7ea..ad3046f19 100644 --- a/src/Http/Controllers/RepositoryIndexController.php +++ b/src/Http/Controllers/RepositoryIndexController.php @@ -11,15 +11,17 @@ class RepositoryIndexController extends RepositoryController { /** * @param RestifyRequest $request - * @return \Illuminate\Http\JsonResponse + * @return \Binaryk\LaravelRestify\Repositories\Repository * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException + * @throws \Binaryk\LaravelRestify\Exceptions\InstanceOfException * @throws \Binaryk\LaravelRestify\Exceptions\UnauthorizedException * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Throwable */ public function handle(RestifyRequest $request) { - $data = $this->search($request->newRepository()); + $data = $this->paginator($request->newRepository()); - return $this->respond($data); + return $request->newRepositoryWith($data)->index($request, $data); } } diff --git a/src/Http/Controllers/RepositoryShowController.php b/src/Http/Controllers/RepositoryShowController.php new file mode 100644 index 000000000..0815f7c46 --- /dev/null +++ b/src/Http/Controllers/RepositoryShowController.php @@ -0,0 +1,22 @@ + + */ +class RepositoryShowController extends RepositoryController +{ + /** + * @param RestifyRequest $request + * @return \Binaryk\LaravelRestify\Controllers\RestResponse|mixed + * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Throwable + */ + public function handle(RestifyRequest $request) + { + return $request->newRepositoryWith($request->findModelQuery())->show($request); + } +} diff --git a/src/Http/Controllers/RepositoryStoreController.php b/src/Http/Controllers/RepositoryStoreController.php new file mode 100644 index 000000000..a91f71b2b --- /dev/null +++ b/src/Http/Controllers/RepositoryStoreController.php @@ -0,0 +1,45 @@ + + */ +class RepositoryStoreController extends RepositoryController +{ + /** + * @param RepositoryStoreRequest $request + * @return JsonResponse + * @throws BindingResolutionException + * @throws EntityNotFoundException + * @throws UnauthorizedException + * @throws AuthorizationException + * @throws Throwable + */ + public function handle(RepositoryStoreRequest $request) + { + /** + * @var Repository + */ + $repository = $request->repository(); + + $repository::authorizeToCreate($request); + + $validator = $repository::validatorForStoring($request); + + if ($validator->fails()) { + return $this->response()->invalid()->errors($validator->errors()->toArray())->respond(); + } + + return $request->newRepositoryWith($repository::newModel())->store($request); + } +} diff --git a/src/Http/Controllers/RepositoryUpdateController.php b/src/Http/Controllers/RepositoryUpdateController.php new file mode 100644 index 000000000..547b9c163 --- /dev/null +++ b/src/Http/Controllers/RepositoryUpdateController.php @@ -0,0 +1,46 @@ + + */ +class RepositoryUpdateController extends RepositoryController +{ + /** + * @param RepositoryStoreRequest $request + * @return JsonResponse + * @throws BindingResolutionException + * @throws EntityNotFoundException + * @throws UnauthorizedException + * @throws AuthorizationException + * @throws Throwable + */ + public function handle(RepositoryUpdateRequest $request) + { + $model = $request->findModelQuery()->lockForUpdate()->firstOrFail(); + + /** + * @var Repository + */ + $repository = $request->newRepositoryWith($model); + $repository->authorizeToUpdate($request); + $validator = $repository::validatorForUpdate($request, $repository); + + if ($validator->fails()) { + return $this->response()->invalid()->errors($validator->errors()->toArray())->respond(); + } + + return $repository->update($request, $model); + } +} diff --git a/src/Http/Requests/InteractWithRepositories.php b/src/Http/Requests/InteractWithRepositories.php index ccc374529..adff405c0 100644 --- a/src/Http/Requests/InteractWithRepositories.php +++ b/src/Http/Requests/InteractWithRepositories.php @@ -68,7 +68,7 @@ public function rules() * Get the route handling the request. * * @param string|null $param - * @param mixed $default + * @param mixed $default * @return \Illuminate\Routing\Route|object|string */ abstract public function route($param = null, $default = null); @@ -103,4 +103,57 @@ public function isResolvedByRestify() return true; } } + + /** + * Get a new instance of the repository being requested. + * As a model it could accept either a model instance, a collection or even paginated collection. + * + * @param $model + * @return Repository + */ + public function newRepositoryWith($model) + { + $repository = $this->repository(); + + return new $repository($model); + } + + /** + * Get a new, scopeless query builder for the underlying model. + * + * @return \Illuminate\Database\Eloquent\Builder + * @throws EntityNotFoundException + * @throws UnauthorizedException + */ + public function newQueryWithoutScopes() + { + return $this->model()->newQueryWithoutScopes(); + } + + /** + * Get a new instance of the underlying model. + * + * @return \Illuminate\Database\Eloquent\Model + * @throws EntityNotFoundException + * @throws UnauthorizedException + */ + public function model() + { + $repository = $this->repository(); + + return $repository::newModel(); + } + + /** + * Get the query to find the model instance for the request. + * + * @param mixed|null $repositoryId + * @return \Illuminate\Database\Eloquent\Builder + */ + public function findModelQuery($repositoryId = null) + { + return $this->newQueryWithoutScopes()->whereKey( + $repositoryId ?? request('repositoryId') + ); + } } diff --git a/src/Http/Requests/RepositoryDestroyRequest.php b/src/Http/Requests/RepositoryDestroyRequest.php new file mode 100644 index 000000000..3ff5e9447 --- /dev/null +++ b/src/Http/Requests/RepositoryDestroyRequest.php @@ -0,0 +1,10 @@ + + */ +class RepositoryDestroyRequest extends RestifyRequest +{ +} diff --git a/src/Http/Requests/ResourceIndexRequest.php b/src/Http/Requests/RepositoryIndexRequest.php similarity index 71% rename from src/Http/Requests/ResourceIndexRequest.php rename to src/Http/Requests/RepositoryIndexRequest.php index 32a25469f..0284849f0 100644 --- a/src/Http/Requests/ResourceIndexRequest.php +++ b/src/Http/Requests/RepositoryIndexRequest.php @@ -5,6 +5,6 @@ /** * @author Eduard Lupacescu */ -class ResourceIndexRequest extends RestifyRequest +class RepositoryIndexRequest extends RestifyRequest { } diff --git a/src/Http/Requests/RepositoryStoreRequest.php b/src/Http/Requests/RepositoryStoreRequest.php new file mode 100644 index 000000000..edbd97433 --- /dev/null +++ b/src/Http/Requests/RepositoryStoreRequest.php @@ -0,0 +1,10 @@ + + */ +class RepositoryStoreRequest extends RestifyRequest +{ +} diff --git a/src/Http/Requests/RepositoryUpdateRequest.php b/src/Http/Requests/RepositoryUpdateRequest.php new file mode 100644 index 000000000..20c8f9f59 --- /dev/null +++ b/src/Http/Requests/RepositoryUpdateRequest.php @@ -0,0 +1,10 @@ + + */ +class RepositoryUpdateRequest extends RestifyRequest +{ +} diff --git a/src/Repositories/Crudable.php b/src/Repositories/Crudable.php new file mode 100644 index 000000000..fcfe98282 --- /dev/null +++ b/src/Repositories/Crudable.php @@ -0,0 +1,106 @@ + + */ +trait Crudable +{ + /** + * @param RestifyRequest $request + * @param Paginator $paginated + * @return JsonResponse + */ + public function index(RestifyRequest $request, Paginator $paginated) + { + return (new static($paginated))->response(); + } + + /** + * @param RestifyRequest $request + * @return JsonResponse + * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Throwable + */ + public function show(RestifyRequest $request) + { + $repository = $request->newRepositoryWith(tap(SearchService::instance()->prepareRelations($request, $request->findModelQuery()), function ($query) use ($request) { + $request->newRepository()->detailQuery($request, $query); + })->firstOrFail()); + + $repository->authorizeToView($request); + + return $repository->response(); + } + + /** + * @param RestifyRequest $request + * @return JsonResponse + */ + public function store(RestifyRequest $request) + { + $model = DB::transaction(function () use ($request) { + $model = self::fillWhenStore( + $request, self::newModel() + ); + + $model->save(); + + return $model; + }); + + return (new static ($model)) + ->response() + ->setStatusCode(RestResponse::REST_RESPONSE_CREATED_CODE) + ->header('Location', Restify::path().'/'.static::uriKey().'/'.$model->id); + } + + /** + * @param RestifyRequest $request + * @param $model + * @return JsonResponse + */ + public function update(RestifyRequest $request, $model) + { + DB::transaction(function () use ($request, $model) { + $model = static::fillWhenUpdate($request, $model); + + $model->save(); + + return $this; + }); + + return $this->response()->setStatusCode(RestResponse::REST_RESPONSE_UPDATED_CODE); + } + + /** + * @param RestifyRequest $request + * @return JsonResponse + */ + public function destroy(RestifyRequest $request) + { + DB::transaction(function () use ($request) { + $model = $request->findModelQuery(); + + return $model->delete(); + }); + + return $this->response() + ->setStatusCode(RestResponse::REST_RESPONSE_DELETED_CODE); + } + + /** + * @param null $request + * @return mixed + */ + abstract public function response($request = null); +} diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 9e59ac952..b239cc0db 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -3,23 +3,36 @@ namespace Binaryk\LaravelRestify\Repositories; use Binaryk\LaravelRestify\Contracts\RestifySearchable; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Traits\InteractWithSearch; +use Binaryk\LaravelRestify\Traits\PerformsQueries; +use Illuminate\Container\Container; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Resources\DelegatesToResource; +use Illuminate\Support\Collection; use Illuminate\Support\Str; /** + * This class serve as repository collection and repository single model + * This allow you to use all of the Laravel default repositories features (as adding headers in the response, or customizing + * response). * @author Eduard Lupacescu */ -abstract class Repository implements RestifySearchable +abstract class Repository extends RepositoryCollection implements RestifySearchable { use InteractWithSearch, - DelegatesToResource; + ValidatingTrait, + RepositoryFillFields, + PerformsQueries, + ResponseResolver, + Crudable; /** * This is named `resource` because of the forwarding properties from DelegatesToResource trait. - * @var Model + * This may be a single model or a illuminate collection, or even a paginator instance. + * + * @var Model|LengthAwarePaginator */ public $resource; @@ -30,16 +43,21 @@ abstract class Repository implements RestifySearchable */ public function __construct($model) { + parent::__construct($model); $this->resource = $model; } /** * Get the underlying model instance for the resource. * - * @return \Illuminate\Database\Eloquent\Model + * @return \Illuminate\Database\Eloquent\Model|LengthAwarePaginator */ public function model() { + if ($this->isRenderingCollection() || $this->isRenderingPaginated()) { + return $this->modelFromIterator() ?? static::newModel(); + } + return $this->resource; } @@ -75,11 +93,37 @@ public static function query() /** * @return array + * @throws \Illuminate\Contracts\Container\BindingResolutionException */ - public function toArray() + public function toArray($request) { - $model = $this->model(); + $request = Container::getInstance()->make('request'); + + if ($this->isRenderingCollection()) { + return $this->toArrayForCollection($request); + } + + $serialized = [ + 'id' => $this->when($this->isRenderingRepository(), function () { + return $this->getKey(); + }), + 'type' => method_exists($this, 'uriKey') ? static::uriKey() : Str::plural(Str::kebab(class_basename(get_called_class()))), + 'attributes' => $this->resolveDetailsAttributes($request), + 'relationships' => $this->when(value($this->resolveDetailsRelationships($request)), $this->resolveDetailsRelationships($request)), + 'meta' => $this->when(value($this->resolveDetailsMeta($request)), $this->resolveDetailsMeta($request)), + ]; + + return $this->resolveDetails($serialized); + } + + abstract public function fields(RestifyRequest $request); - return $model->toArray(); + /** + * @param RestifyRequest $request + * @return Collection + */ + public function collectFields(RestifyRequest $request) + { + return collect($this->fields($request)); } } diff --git a/src/Repositories/RepositoryCollection.php b/src/Repositories/RepositoryCollection.php new file mode 100644 index 000000000..c0505a4ff --- /dev/null +++ b/src/Repositories/RepositoryCollection.php @@ -0,0 +1,140 @@ + + */ +class RepositoryCollection extends Resource +{ + /** + * When the repository is used as a response for a collection list (index controller). + * + * @param $request + * @return array + */ + public function toArrayForCollection($request) + { + $paginated = parent::toArray($request); + + $currentRepository = Restify::repositoryForModel(get_class($this->model())); + + if (is_null($currentRepository)) { + return parent::toArray($request); + } + + $data = collect([]); + $iterator = $this->iterator(); + + while ($iterator->valid()) { + $data->push($iterator->current()); + $iterator->next(); + } + + $response = $data->mapInto($currentRepository)->toArray($request); + + return [ + 'meta' => $this->when($this->isRenderingPaginated(), $this->meta($paginated)), + 'links' => $this->when($this->isRenderingPaginated(), $this->paginationLinks($paginated)), + 'data' => $response, + ]; + } + + /** + * Get the pagination links for the response. + * + * @param array $paginated + * @return array + */ + protected function paginationLinks($paginated) + { + return [ + 'first' => $paginated['first_page_url'] ?? null, + 'last' => $paginated['last_page_url'] ?? null, + 'prev' => $paginated['prev_page_url'] ?? null, + 'next' => $paginated['next_page_url'] ?? null, + ]; + } + + /** + * Gather the meta data for the response. + * + * @param array $paginated + * @return array + */ + protected function meta($paginated) + { + return Arr::except($paginated, [ + 'data', + 'first_page_url', + 'last_page_url', + 'prev_page_url', + 'next_page_url', + ]); + } + + /** + * Check if the repository is used as a response for a list of items or for a single + * model entity. + * @return bool + */ + protected function isRenderingRepository() + { + return $this->resource instanceof Model; + } + + /** + * Check if the repository is used as a response for a list of items or for a single + * model entity. + * @return bool + */ + protected function isRenderingCollection() + { + return false === $this->resource instanceof Model; + } + + /** + * @return bool + */ + public function isRenderingPaginated() + { + return $this->resource instanceof AbstractPaginator; + } + + /** + * If collection or paginator then return model from the first item. + * + * @return Model + */ + protected function modelFromIterator() + { + /** + * @var ArrayIterator + */ + $iterator = $this->iterator(); + + /** + * This is the first element from the response collection, now we have the class of the restify + * engine. + * @var Model + */ + $model = $iterator->current(); + + return $model; + } + + /** + * @return ArrayIterator + */ + protected function iterator() + { + return $this->resource->getIterator(); + } +} diff --git a/src/Repositories/RepositoryFillFields.php b/src/Repositories/RepositoryFillFields.php new file mode 100644 index 000000000..df4fdfe0f --- /dev/null +++ b/src/Repositories/RepositoryFillFields.php @@ -0,0 +1,90 @@ + + */ +trait RepositoryFillFields +{ + /** + * Fill fields on store request. + * + * @param RestifyRequest $request + * @param $model + * @return array + */ + public static function fillWhenStore(RestifyRequest $request, $model) + { + static::fillFields( + $request, $model, + (new static($model))->collectFields($request) + ); + + static::fillExtra($request, $model, + (new static($model))->collectFields($request) + ); + + return $model; + } + + /** + * @param RestifyRequest $request + * @param $model + * @return array + */ + public static function fillWhenUpdate(RestifyRequest $request, $model) + { + static::fillFields( + $request, $model, + (new static($model))->collectFields($request) + ); + static::fillExtra($request, $model, + (new static($model))->collectFields($request) + ); + + return $model; + } + + /** + * Fill each field separately. + * + * @param RestifyRequest $request + * @param Model $model + * @param Collection $fields + * @return Model + */ + protected static function fillFields(RestifyRequest $request, Model $model, Collection $fields) + { + $fields->map(function (Field $field) use ($request, $model) { + return $field->fillAttribute($request, $model); + })->values()->all(); + + return $model; + } + + /** + * If some fields were not defined in the @fields method, but they are in fillable attributes and present in request, + * they should be also filled on request. + * @param RestifyRequest $request + * @param Model $model + * @param Collection $fields + * @return array + */ + protected static function fillExtra(RestifyRequest $request, Model $model, Collection $fields) + { + $definedAttributes = $fields->map->getAttribute()->toArray(); + $fromRequest = collect($request->only($model->getFillable()))->keys()->filter(function ($attribute) use ($definedAttributes) { + return ! in_array($attribute, $definedAttributes); + }); + + return $fromRequest->each(function ($attribute) use ($request, $model) { + $model->{$attribute} = $request->{$attribute}; + })->values()->all(); + } +} diff --git a/src/Repositories/ResponseResolver.php b/src/Repositories/ResponseResolver.php new file mode 100644 index 000000000..00ca9556b --- /dev/null +++ b/src/Repositories/ResponseResolver.php @@ -0,0 +1,87 @@ + + */ +trait ResponseResolver +{ + /** + * Return the attributes list. + * + * @param $request + * @return array + */ + public function resolveDetailsAttributes($request) + { + return parent::toArray($request); + } + + /** + * @param $request + * @return array + */ + public function resolveDetailsMeta($request) + { + return [ + 'authorizedToView' => $this->authorizedToView($request), + 'authorizedToCreate' => $this->authorizedToCreate($request), + 'authorizedToUpdate' => $this->authorizedToUpdate($request), + 'authorizedToDelete' => $this->authorizedToDelete($request), + ]; + } + + /** + * Return a list with relationship for the current model. + * + * @param $request + * @return array + */ + public function resolveDetailsRelationships($request) + { + return []; + } + + /** + * Triggered after toArray. + * + * @param $serialized + * @return array + */ + public function resolveDetails($serialized) + { + return $serialized; + } + + /** + * Return the attributes list. + * + * @param $request + * @return array + */ + public function resolveIndexAttributes($request) + { + return $this->resolveDetailsAttributes($request); + } + + /** + * @param $request + * @return array + */ + public function resolveIndexMeta($request) + { + return $this->resolveDetailsMeta($request); + } + + /** + * Return a list with relationship for the current model. + * + * @param $request + * @return array + */ + public function resolveIndexRelationships($request) + { + return $this->resolveDetailsRelationships($request); + } +} diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php new file mode 100644 index 000000000..dbebf5669 --- /dev/null +++ b/src/Repositories/ValidatingTrait.php @@ -0,0 +1,142 @@ + + */ +trait ValidatingTrait +{ + /** + * @param RestifyRequest $request + * @return Collection + */ + abstract public function collectFields(RestifyRequest $request); + + /** + * @return mixed + */ + abstract public static function newModel(); + + /** + * @param RestifyRequest $request + * @return \Illuminate\Contracts\Validation\Validator + */ + public static function validatorForStoring(RestifyRequest $request) + { + $on = (new static(static::newModel())); + + $messages = $on->collectFields($request)->flatMap(function ($k) { + $messages = []; + foreach ($k->messages as $ruleFor => $message) { + $messages[$k->attribute.'.'.$ruleFor] = $message; + } + + return $messages; + })->toArray(); + + return Validator::make($request->all(), $on->getStoringRules($request), $messages)->after(function ($validator) use ($request) { + static::afterValidation($request, $validator); + static::afterStoringValidation($request, $validator); + }); + } + + /** + * Validate a resource update request. + * @param RestifyRequest $request + * @param null $resource + */ + public static function validateForUpdate(RestifyRequest $request, $resource = null) + { + static::validatorForUpdate($request, $resource)->validate(); + } + + /** + * @param RestifyRequest $request + * @param null $resource + * @return \Illuminate\Contracts\Validation\Validator + */ + public static function validatorForUpdate(RestifyRequest $request, $resource = null) + { + $on = $resource ?? (new static(static::newModel())); + + $messages = $on->collectFields($request)->flatMap(function ($k) { + $messages = []; + foreach ($k->messages as $ruleFor => $message) { + $messages[$k->attribute.'.'.$ruleFor] = $message; + } + + return $messages; + })->toArray(); + + return Validator::make($request->all(), $on->getUpdatingRules($request), $messages)->after(function ($validator) use ($request) { + static::afterValidation($request, $validator); + static::afterUpdatingValidation($request, $validator); + }); + } + + /** + * Handle any post-validation processing. + * + * @param RestifyRequest $request + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + protected static function afterValidation(RestifyRequest $request, $validator) + { + // + } + + /** + * Handle any post-storing validation processing. + * + * @param RestifyRequest $request + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + protected static function afterStoringValidation(RestifyRequest $request, $validator) + { + } + + /** + * Handle any post-storing validation processing. + * + * @param RestifyRequest $request + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + protected static function afterUpdatingValidation(RestifyRequest $request, $validator) + { + } + + /** + * @param RestifyRequest $request + * @return array + */ + public function getStoringRules(RestifyRequest $request) + { + return $this->collectFields($request)->mapWithKeys(function (Field $k) { + return [ + $k->attribute => $k->getStoringRules(), + ]; + })->toArray(); + } + + /** + * @param RestifyRequest $request + * @return array + */ + public function getUpdatingRules(RestifyRequest $request) + { + return $this->collectFields($request)->mapWithKeys(function (Field $k) { + return [ + $k->attribute => $k->getUpdatingRules(), + ]; + })->toArray(); + } +} diff --git a/src/Restify.php b/src/Restify.php index 229f799c0..96beb11dc 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -48,6 +48,19 @@ public static function repositoryForKey($key) }); } + /** + * Get the repository class name for a given key. + * + * @param string $model + * @return string + */ + public static function repositoryForModel($model) + { + return collect(static::$repositories)->first(function ($value) use ($model) { + return $value::$model === $model; + }); + } + /** * Register the given repositories. * diff --git a/src/RestifyServiceProvider.php b/src/RestifyServiceProvider.php index fdae7d453..89a62b098 100644 --- a/src/RestifyServiceProvider.php +++ b/src/RestifyServiceProvider.php @@ -5,6 +5,10 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +/** + * This provider is injected in console context by the main provider or by the RestifyInjector + * if a restify request. + */ class RestifyServiceProvider extends ServiceProvider { /** diff --git a/src/Services/Search/SearchService.php b/src/Services/Search/SearchService.php index 83d8a38a5..37829c8f2 100644 --- a/src/Services/Search/SearchService.php +++ b/src/Services/Search/SearchService.php @@ -17,12 +17,12 @@ class SearchService extends Searchable { /** * @param RestifyRequest $request - * @param Model $model + * @param $model * @return Builder * @throws InstanceOfException * @throws \Throwable */ - public function search(RestifyRequest $request, Model $model) + public function search(RestifyRequest $request, $model) { if (! $model instanceof RestifySearchable) { return $model->newQuery(); diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index 54da86a4d..b834508c6 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -2,8 +2,6 @@ namespace Binaryk\LaravelRestify\Traits; -use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; - /** * @author Eduard Lupacescu */ @@ -52,26 +50,4 @@ public static function getOrderByFields() { return static::$sort ?? []; } - - /** - * Prepare the resource for JSON serialization. - * - * @param RestifyRequest $request - * @param array $fields - * @return array - */ - public function serializeForIndex(RestifyRequest $request, array $fields = null) - { - return array_merge($fields ?: $this->toArray(), [ - 'authorizedToView' => $this->authorizedToView($request), - 'authorizedToCreate' => $this->authorizedToCreate($request), - 'authorizedToUpdate' => $this->authorizedToUpdate($request), - 'authorizedToDelete' => $this->authorizedToDelete($request), - ]); - } - - /** - * @return array - */ - abstract public function toArray(); } diff --git a/src/Traits/PerformsRequestValidation.php b/src/Traits/PerformsRequestValidation.php new file mode 100644 index 000000000..ab4470412 --- /dev/null +++ b/src/Traits/PerformsRequestValidation.php @@ -0,0 +1,10 @@ + + */ +trait PerformsRequestValidation +{ +} diff --git a/tests/Controllers/RepositoryIndexControllerTest.php b/tests/Controllers/RepositoryIndexControllerTest.php index 9872a5f92..6b24fe3bc 100644 --- a/tests/Controllers/RepositoryIndexControllerTest.php +++ b/tests/Controllers/RepositoryIndexControllerTest.php @@ -5,6 +5,7 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Controllers\RestController; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Fixtures\User; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Mockery; @@ -23,12 +24,12 @@ public function test_list_resource() $response = $this->withExceptionHandling() ->getJson('/restify-api/users'); - $response->assertJsonCount(3, 'data.data'); + $response->assertJsonCount(3, 'data'); } public function test_the_rest_controller_can_paginate() { - $this->mockUsers(50); + $this->mockUsers(20); $class = (new class extends RestController { public function users() @@ -73,47 +74,54 @@ public function users() public function test_search_query_works() { $users = $this->mockUsers(10, ['eduard.lupacescu@binarcode.com']); - $expected = $users->where('email', 'eduard.lupacescu@binarcode.com')->first()->serializeForIndex(Mockery::mock(RestifyRequest::class)); + $request = Mockery::mock(RestifyRequest::class); + $model = $users->where('email', 'eduard.lupacescu@binarcode.com')->first(); //find manually the model + $repository = Restify::repositoryForModel(get_class($model)); + $expected = (new $repository($model))->toArray($request); + unset($expected['relationships']); + $this->withExceptionHandling() ->getJson('/restify-api/users?search=eduard.lupacescu@binarcode.com') ->assertStatus(200) ->assertJson([ - 'data' => [ - 'data' => [$expected], + 'links' => [ + 'last' => 'http://localhost/restify-api/users?page=1', + 'next' => null, + 'first' => 'http://localhost/restify-api/users?page=1', + 'prev' => null, + ], + 'meta' => [ + 'path' => 'http://localhost/restify-api/users', 'current_page' => 1, - 'first_page_url' => 'http://localhost/restify-api/users?page=1', 'from' => 1, 'last_page' => 1, - 'last_page_url' => 'http://localhost/restify-api/users?page=1', - 'next_page_url' => null, - 'path' => 'http://localhost/restify-api/users', 'per_page' => 15, - 'prev_page_url' => null, 'to' => 1, 'total' => 1, ], - 'errors' => [], + 'data' => [$expected], ]); $this->withExceptionHandling() ->getJson('/restify-api/users?search=some_unexpected_string_here') ->assertStatus(200) ->assertJson([ - 'data' => [ - 'data' => [], + 'links' => [ + 'next' => null, + 'last' => 'http://localhost/restify-api/users?page=1', + 'first' => 'http://localhost/restify-api/users?page=1', + 'prev' => null, + ], + 'meta' => [ 'current_page' => 1, - 'first_page_url' => 'http://localhost/restify-api/users?page=1', 'from' => 1, 'last_page' => 1, - 'last_page_url' => 'http://localhost/restify-api/users?page=1', - 'next_page_url' => null, - 'path' => 'http://localhost/restify-api/users', 'per_page' => 15, - 'prev_page_url' => null, 'to' => 1, + 'path' => 'http://localhost/restify-api/users', 'total' => 1, ], - 'errors' => [], + 'data' => [], ]); } @@ -124,81 +132,91 @@ public function test_that_desc_sort_query_param_works() ->assertStatus(200) ->getOriginalContent(); - $this->assertSame($response->data['data'][0]['id'], 10); - $this->assertSame($response->data['data'][9]['id'], 1); + $this->assertSame($response->getCollection()->first()->id, 10); + $this->assertSame($response->getCollection()->last()->id, 1); } public function test_that_asc_sort_query_param_works() { $this->mockUsers(10); - $response = $this->withExceptionHandling()->get('/restify-api/users?sort=+id') + $response = (array) json_decode($this->withExceptionHandling()->get('/restify-api/users?sort=+id') ->assertStatus(200) - ->getOriginalContent(); - - $this->assertSame($response->data['data'][0]['id'], 1); - $this->assertSame($response->data['data'][9]['id'], 10); + ->getContent()); - $response = $this->withExceptionHandling()->get('/restify-api/users?sort=id')//assert default ASC sort - ->assertStatus(200) - ->getOriginalContent(); - - $this->assertSame($response->data['data'][0]['id'], 1); - $this->assertSame($response->data['data'][9]['id'], 10); + $this->assertSame(data_get($response, 'data.0.id'), 1); + $this->assertSame(data_get($response, 'data.9.id'), 10); } public function test_that_default_asc_sort_query_param_works() { $this->mockUsers(10); - $response = $this->withExceptionHandling()->get('/restify-api/users?sort=id') + $response = (array) json_decode($this->withExceptionHandling()->get('/restify-api/users?sort=id') ->assertStatus(200) - ->getOriginalContent(); + ->getContent()); - $this->assertSame($response->data['data'][0]['id'], 1); - $this->assertSame($response->data['data'][9]['id'], 10); + $this->assertSame(data_get($response, 'data.0.id'), 1); + $this->assertSame(data_get($response, 'data.9.id'), 10); } public function test_that_match_param_works() { User::$match = ['email' => RestifySearchable::MATCH_TEXT]; // it will automatically filter over these queries (email='test@email.com') $users = $this->mockUsers(10, ['eduard.lupacescu@binarcode.com']); - $expected = $users->where('email', 'eduard.lupacescu@binarcode.com')->first()->serializeForIndex(Mockery::mock(RestifyRequest::class)); - + $request = Mockery::mock(RestifyRequest::class); + $request->shouldReceive('has') + ->andReturnFalse(); + $request->shouldReceive('get') + ->andReturnFalse(); + + $model = $users->where('email', 'eduard.lupacescu@binarcode.com')->first(); + $repository = Restify::repositoryForModel(get_class($model)); + $expected = (new $repository($model))->toArray($request); + unset($expected['relationships']); $this->withExceptionHandling() ->get('/restify-api/users?email=eduard.lupacescu@binarcode.com') ->assertStatus(200) ->assertJson([ - 'data' => [ - 'data' => [$expected], + 'links' => [ + 'last' => 'http://localhost/restify-api/users?page=1', + 'next' => null, + 'first' => 'http://localhost/restify-api/users?page=1', + 'prev' => null, + ], + 'meta' => [ 'current_page' => 1, - 'first_page_url' => 'http://localhost/restify-api/users?page=1', + 'path' => 'http://localhost/restify-api/users', 'from' => 1, 'last_page' => 1, - 'last_page_url' => 'http://localhost/restify-api/users?page=1', - 'next_page_url' => null, - 'path' => 'http://localhost/restify-api/users', 'per_page' => 15, - 'prev_page_url' => null, 'to' => 1, 'total' => 1, ], - 'errors' => [], + 'data' => [$expected], ]); } public function test_that_with_param_works() { User::$match = ['email' => RestifySearchable::MATCH_TEXT]; // it will automatically filter over these queries (email='test@email.com') - $users = $this->mockUsers(1); + $this->mockUsers(1); $posts = $this->mockPosts(1, 2); - $expected = $users->first()->serializeForIndex(Mockery::mock(RestifyRequest::class)); - $expected['posts'] = $posts->toArray(); + $request = Mockery::mock(RestifyRequest::class); + $request->shouldReceive('has') + ->andReturnTrue(); + $request->shouldReceive('get') + ->andReturn('posts'); + $r = $this->withExceptionHandling() - ->get('/restify-api/users?with=posts') + ->getJson('/restify-api/users?with=posts') ->assertStatus(200) - ->getOriginalContent(); + ->getContent(); + $r = (array) json_decode($r); - $this->assertSameSize($r->data['data']->first()['posts'], $expected['posts']); + $this->assertSameSize((array) data_get($r, 'data.0.relationships.posts'), $posts->toArray()); + $this->assertSame(array_keys((array) data_get($r, 'data.0.relationships.posts.0')), [ + 'id', 'type', 'attributes', 'meta', + ]); } } diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php new file mode 100644 index 000000000..bc82b7b30 --- /dev/null +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -0,0 +1,50 @@ + + */ +class RepositoryStoreControllerTest extends IntegrationTest +{ + protected function setUp(): void + { + parent::setUp(); + } + + public function test_basic_validation_works() + { + $this->withExceptionHandling()->post('/restify-api/posts', [ + 'title' => 'Title', + ]) + ->assertStatus(400) + ->assertJson([ + 'errors' => [ + 'description' => [ + 'Description field is required bro.', + ], + ], + ]); + } + + public function test_success_storing() + { + $user = $this->mockUsers()->first(); + $r = json_decode($this->withExceptionHandling()->post('/restify-api/posts', [ + 'user_id' => $user->id, + 'title' => 'Some post title', + 'description' => 'A very short description', + ]) + ->assertStatus(201) + ->assertHeader('Location', '/restify-api/posts/1') + ->getContent()); + + $this->assertEquals($r->data->attributes->title, 'Some post title'); + $this->assertEquals($r->data->attributes->description, 'A very short description'); + $this->assertEquals($r->data->attributes->user_id, $user->id); + $this->assertEquals($r->data->id, 1); + $this->assertEquals($r->data->type, 'posts'); + } +} diff --git a/tests/Fixtures/PostRepository.php b/tests/Fixtures/PostRepository.php index c551686ab..d7f45312c 100644 --- a/tests/Fixtures/PostRepository.php +++ b/tests/Fixtures/PostRepository.php @@ -2,6 +2,8 @@ namespace Binaryk\LaravelRestify\Tests\Fixtures; +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; /** @@ -20,4 +22,20 @@ public static function uriKey() { return 'posts'; } + + /** + * @param RestifyRequest $request + * @return array + */ + public function fields(RestifyRequest $request) + { + return [ + Field::make('title')->storingRules('required')->messages([ + 'required' => 'This field is required bro.', + ]), + Field::make('description')->storingRules('required')->messages([ + 'required' => 'Description field is required bro.', + ]), + ]; + } } diff --git a/tests/Fixtures/UserRepository.php b/tests/Fixtures/UserRepository.php index 57292911d..397badde6 100644 --- a/tests/Fixtures/UserRepository.php +++ b/tests/Fixtures/UserRepository.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Tests\Fixtures; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; /** @@ -20,4 +21,17 @@ public static function uriKey() { return 'users'; } + + public function fields(RestifyRequest $request) + { + return [ + ]; + } + + public function resolveDetailsRelationships($request) + { + return [ + 'posts' => PostRepository::collection($this->whenLoaded('posts')), + ]; + } } diff --git a/tests/RestControllerTest.php b/tests/RestControllerTest.php index 6cac26031..50796c1cc 100644 --- a/tests/RestControllerTest.php +++ b/tests/RestControllerTest.php @@ -102,7 +102,7 @@ public function test_making_custom_response_message() Gate::shouldReceive('check') ->andReturnTrue(); $response = $this->controller->destroy($user->id); - $this->assertSame($response->getData()->message, 'User deleted.'); + $this->assertSame($response->getData()->meta->message, 'User deleted.'); } public function test_can_access_config_repository()