diff --git a/.env.example b/.env.example index 8eb8f575ed4..3684794585d 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ENV=local APP_DEBUG=true APP_KEY=SomeRandomString +DB_CONNECTION=sqlite DB_HOST=localhost DB_DATABASE=homestead DB_USERNAME=homestead diff --git a/.gitignore b/.gitignore index 2ff24d0f291..c6e0de0df10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -/vendor -/node_modules +vendor/ +node_modules/ +.idea/ Homestead.yaml Homestead.json .env +composer.lock +storage/database.sqlite +_ide_helper.php diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..45e6caaad63 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: php +php: +- 5.5 +- 5.6 +- hhvm +before_script: +- travis_retry composer self-update +- travis_retry composer install --no-interaction --prefer-dist +- cp .env.example .env +- php artisan key:generate +- touch storage/database.sqlite +- php artisan migrate --force +- php artisan db:seed --force +script: +- phpunit diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 3dabc68cb26..717a6e78f5f 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -1,51 +1,251 @@ -integration = new LaravelIntegration(); + + $extensionsClosure = function () { + /** @var SupportedExtensionsInterface $supportedExtensions */ + $supportedExtensions = app()->resolved(SupportedExtensionsInterface::class) === false ? null : + app()->make(SupportedExtensionsInterface::class); + return $supportedExtensions; + }; + + $this->renderContainer = new RenderContainer(new Factory(), $this->integration, $extensionsClosure); + + $this->registerCustomExceptions(); + } + /** * A list of the exception types that should not be reported. * * @var array */ protected $dontReport = [ - HttpException::class, + GoneHttpException::class, + ValidationException::class, + ConflictHttpException::class, + NotFoundHttpException::class, ModelNotFoundException::class, + BadRequestHttpException::class, + UnexpectedValueException::class, + AccessDeniedHttpException::class, + UnauthorizedHttpException::class, + NotAcceptableHttpException::class, + LengthRequiredHttpException::class, + TooManyRequestsHttpException::class, + MethodNotAllowedHttpException::class, + PreconditionFailedHttpException::class, + PreconditionRequiredHttpException::class, + UnsupportedMediaTypeHttpException::class, ]; /** - * Report or log an exception. + * Render an exception into an HTTP response. * - * This is a great spot to send exceptions to Sentry, Bugsnag, etc. + * @param Request $request + * @param Exception $exception * - * @param \Exception $e - * @return void + * @return Response */ - public function report(Exception $e) + public function render($request, Exception $exception) { - return parent::report($e); + $render = $this->renderContainer->getRender($exception); + return $render($request, $exception); } /** - * Render an exception into an HTTP response. + * Here you can add 'exception -> HTTP code' mapping or custom exception renders. + */ + private function registerCustomExceptions() + { + $this->renderContainer->registerHttpCodeMapping([ + + MassAssignmentException::class => Response::HTTP_FORBIDDEN, + UnexpectedValueException::class => Response::HTTP_BAD_REQUEST, + + ]); + + // + // That's an example of how to create custom response with JSON API Error. + // + $custom404render = $this->getCustom404Render(); + + // Another example how Eloquent ValidationException could be used. + // You can use validation as simple as this + // + // /** @var \Illuminate\Validation\Validator $validator */ + // if ($validator->fails()) { + // throw new ValidationException($validator); + // } + // + // and it will return JSON-API error(s) from your API service + $customValidationRender = $this->getCustomValidationRender(); + + // This render is interesting because it takes HTTP Headers from exception and + // adds them to HTTP Response (via render parameter $headers) + $customTooManyRequestsRender = $this->getCustomTooManyRequestsRender(); + + $this->renderContainer->registerRender(ModelNotFoundException::class, $custom404render); + $this->renderContainer->registerRender(ValidationException::class, $customValidationRender); + $this->renderContainer->registerRender(TooManyRequestsHttpException::class, $customTooManyRequestsRender); + } + + /** + * @return Closure + */ + private function getCustom404Render() + { + $custom404render = function (/*Request $request, ModelNotFoundException $exception*/) { + // This render can convert JSON API Error to Response + $jsonApiErrorRender = $this->renderContainer->getErrorsRender(Response::HTTP_NOT_FOUND); + + // Prepare Error object (e.g. take info from the exception) + $title = 'Requested item not found'; + $error = new Error(null, null, null, null, $title); + + // Convert error (note it accepts array of errors) to HTTP response + return $jsonApiErrorRender([$error], $this->getEncoderOptions(), $this->mergeCorsHeadersTo()); + }; + + return $custom404render; + } + + /** + * @return Closure + */ + private function getCustomValidationRender() + { + $customValidationRender = function (Request $request, ValidationException $exception) { + $request ?: null; // avoid 'unused' warning + + // This render can convert JSON API Error to Response + $jsonApiErrorRender = $this->renderContainer->getErrorsRender(Response::HTTP_BAD_REQUEST); + + // Prepare Error object (e.g. take info from the exception) + $title = 'Validation fails'; + $errors = []; + foreach ($exception->errors()->all() as $validationMessage) { + $errors[] = new Error(null, null, null, null, $title, $validationMessage); + } + + // Convert error (note it accepts array of errors) to HTTP response + return $jsonApiErrorRender($errors, $this->getEncoderOptions(), $this->mergeCorsHeadersTo()); + }; + + return $customValidationRender; + } + + /** + * @return Closure + */ + private function getCustomTooManyRequestsRender() + { + $customTooManyRequestsRender = function (Request $request, TooManyRequestsHttpException $exception) { + $request ?: null; // avoid 'unused' warning + + // This render can convert JSON API Error to Response + $jsonApiErrorRender = $this->renderContainer->getErrorsRender(Response::HTTP_TOO_MANY_REQUESTS); + + // Prepare Error object (e.g. take info from the exception) + $title = 'Validation fails'; + $message = $exception->getMessage(); + $headers = $exception->getHeaders(); + $error = new Error(null, null, null, null, $title, $message); + + // Convert error (note it accepts array of errors) to HTTP response + return $jsonApiErrorRender([$error], $this->getEncoderOptions(), $this->mergeCorsHeadersTo($headers)); + }; + + return $customTooManyRequestsRender; + } + + /** + * @return EncoderOptions + */ + private function getEncoderOptions() + { + // Load JSON formatting options from config + $options = array_get( + $this->integration->getConfig(), + C::JSON . '.' . C::JSON_OPTIONS, + C::JSON_OPTIONS_DEFAULT + ); + $encodeOptions = new EncoderOptions($options); + + return $encodeOptions; + } + + /** + * @param array $headers * - * @param \Illuminate\Http\Request $request - * @param \Exception $e - * @return \Illuminate\Http\Response + * @return array */ - public function render($request, Exception $e) + private function mergeCorsHeadersTo(array $headers = []) { - if ($e instanceof ModelNotFoundException) { - $e = new NotFoundHttpException($e->getMessage(), $e); + $resultHeaders = $headers; + if (app()->resolved(AnalysisResultInterface::class) === true) { + /** @var AnalysisResultInterface|null $result */ + $result = app(AnalysisResultInterface::class); + if ($result !== null) { + $resultHeaders = array_merge($headers, $result->getResponseHeaders()); + } } - return parent::render($request, $e); + return $resultHeaders; } } diff --git a/app/Http/Controllers/Demo/AuthorsController.php b/app/Http/Controllers/Demo/AuthorsController.php new file mode 100644 index 00000000000..b93dd552f51 --- /dev/null +++ b/app/Http/Controllers/Demo/AuthorsController.php @@ -0,0 +1,133 @@ +checkParametersEmpty(); + + return $this->getResponse(Author::all()); + } + + /** + * Store a newly created resource in storage. + * + * @return Response + */ + public function store() + { + $this->checkParametersEmpty(); + + $attributes = $this->getFilteredInput(array_get($this->getDocument(), 'data.attributes', [])); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = ['first_name' => 'required|alpha_dash', 'last_name' => 'required|alpha_dash']; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $author = new Author($attributes); + $author->save(); + + return $this->getCreatedResponse($author); + } + + /** + * Display the specified resource. + * + * @param int $id + * + * @return Response + */ + public function show($id) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Author::findOrFail($id)); + } + + /** + * Update the specified resource in storage. + * + * @param int $id + * + * @return Response + */ + public function update($id) + { + $this->checkParametersEmpty(); + + $attributes = $this->getFilteredInput(array_get($this->getDocument(), 'data.attributes', [])); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = ['first_name' => 'sometimes|required|alpha_dash', 'last_name' => 'sometimes|required|alpha_dash']; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $author = Author::findOrFail($id); + $author->fill($attributes); + $author->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return Response + */ + public function destroy($id) + { + $this->checkParametersEmpty(); + + $author = Author::findOrFail($id); + $author->delete(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * @param array $input + * + * @return array + */ + private function getFilteredInput(array $input) + { + return array_filter([ + 'first_name' => array_get($input, 'first'), + 'last_name' => array_get($input, 'last'), + 'twitter' => array_get($input, 'twitter'), + ], function ($value) { + return $value !== null; + }); + } +} diff --git a/app/Http/Controllers/Demo/CommentsController.php b/app/Http/Controllers/Demo/CommentsController.php new file mode 100644 index 00000000000..3530313c703 --- /dev/null +++ b/app/Http/Controllers/Demo/CommentsController.php @@ -0,0 +1,121 @@ +getParameters()->getFilteringParameters()['ids']; + $ids ?: null; // avoid 'unused' warning + + return $this->getResponse(Comment::all()); + } + + /** + * Store a newly created resource in storage. + * + * @return Response + */ + public function store() + { + $this->checkParametersEmpty(); + + $content = $this->getDocument(); + + $attributes = array_get($content, 'data.attributes', []); + $attributes['post_id'] = array_get($content, 'data.links.author.linkage.id', null); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = ['body' => 'required', 'post_id' => 'required|integer']; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $comment = new Comment($attributes); + $comment->save(); + + return $this->getCreatedResponse($comment); + } + + /** + * Display the specified resource. + * + * @param int $id + * + * @return Response + */ + public function show($id) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Comment::findOrFail($id)); + } + + /** + * Update the specified resource in storage. + * + * @param int $id + * + * @return Response + */ + public function update($id) + { + $this->checkParametersEmpty(); + + $content = $this->getDocument(); + + $attributes = array_get($content, 'data.attributes', []); + $postId = array_get($content, 'data.links.author.linkage.id', null); + if ($postId !== null) { + $attributes['post_id'] = $postId; + } + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = ['post_id' => 'sometimes|required|integer']; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $comment = Comment::findOrFail($id); + $comment->fill($attributes); + $comment->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return Response + */ + public function destroy($id) + { + $this->checkParametersEmpty(); + + $comment = Comment::findOrFail($id); + $comment->delete(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Demo/PostsController.php b/app/Http/Controllers/Demo/PostsController.php new file mode 100644 index 00000000000..b0e720c14fd --- /dev/null +++ b/app/Http/Controllers/Demo/PostsController.php @@ -0,0 +1,121 @@ +checkParametersEmpty(); + + return $this->getResponse(Post::all()); + } + + /** + * Store a newly created resource in storage. + * + * @return Response + */ + public function store() + { + $this->checkParametersEmpty(); + + $content = $this->getDocument(); + $attributes = array_get($content, 'data.attributes', []); + + $attributes['author_id'] = array_get($content, 'data.links.author.linkage.id', null); + $attributes['site_id'] = array_get($content, 'data.links.site.linkage.id', null); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = [ + 'title' => 'required', + 'body' => 'required', + 'author_id' => 'required|integer', + 'site_id' => 'required|integer' + ]; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $post = new Post($attributes); + $post->save(); + + return $this->getCreatedResponse($post); + } + + /** + * Display the specified resource. + * + * @param int $id + * @return Response + */ + public function show($id) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Post::findOrFail($id)); + } + + /** + * Update the specified resource in storage. + * + * @param int $id + * @return Response + */ + public function update($id) + { + $this->checkParametersEmpty(); + + $content = $this->getDocument(); + $attributes = array_get($content, 'data.attributes', []); + + $attributes['author_id'] = array_get($content, 'data.links.author.linkage.id', null); + $attributes['site_id'] = array_get($content, 'data.links.site.linkage.id', null); + $attributes = array_filter($attributes, function ($value) {return $value !== null;}); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = [ + 'title' => 'sometimes|required', + 'body' => 'sometimes|required', + 'author_id' => 'sometimes|required|integer', + 'site_id' => 'sometimes|required|integer' + ]; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $post = Post::findOrFail($id); + $post->fill($attributes); + $post->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return Response + */ + public function destroy($id) + { + $this->checkParametersEmpty(); + + $comment = Post::findOrFail($id); + $comment->delete(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Demo/SitesController.php b/app/Http/Controllers/Demo/SitesController.php new file mode 100644 index 00000000000..45d4cafc65e --- /dev/null +++ b/app/Http/Controllers/Demo/SitesController.php @@ -0,0 +1,103 @@ +checkParametersEmpty(); + + return $this->getResponse(Site::all()); + } + + /** + * Store a newly created resource in storage. + * + * @return Response + */ + public function store() + { + $this->checkParametersEmpty(); + + $attributes = array_get($this->getDocument(), 'data.attributes', []); + + /** @var \Illuminate\Validation\Validator $validator */ + $validator = Validator::make($attributes, ['name' => 'required|min:5']); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $site = new Site($attributes); + $site->save(); + + return $this->getCreatedResponse($site); + } + + /** + * Display the specified resource. + * + * @param int $id + * + * @return Response + */ + public function show($id) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Site::findOrFail($id)); + } + + /** + * Update the specified resource in storage. + * + * @param int $id + * + * @return Response + */ + public function update($id) + { + $this->checkParametersEmpty(); + + $attributes = array_get($this->getDocument(), 'data.attributes', []); + + /** @var \Illuminate\Validation\Validator $validator */ + $validator = Validator::make($attributes, ['name' => 'sometimes|required|min:5']); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $site = Site::findOrFail($id); + $site->fill($attributes); + $site->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return Response + */ + public function destroy($id) + { + $this->checkParametersEmpty(); + + $site = Site::findOrFail($id); + $site->delete(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Demo/UsersController.php b/app/Http/Controllers/Demo/UsersController.php new file mode 100644 index 00000000000..d584846555f --- /dev/null +++ b/app/Http/Controllers/Demo/UsersController.php @@ -0,0 +1,134 @@ +checkParametersEmpty(); + + return $this->getResponse(User::all()); + } + + /** + * Store a newly created resource in storage. + * + * @return Response + */ + public function store() + { + $this->checkParametersEmpty(); + + $attributes = array_get($this->getDocument(), 'data.attributes', []); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = [ + 'name' => 'required|string|max:255', + 'email' => 'required|email|max:255|unique:users', + 'password' => 'required|string|min:6|max:255', + ]; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $author = new User($attributes); + $author->save(); + + return $this->getCreatedResponse($author); + } + + /** + * Display the specified resource. + * + * @param int $id + * + * @return Response + */ + public function show($id) + { + $this->checkParametersEmpty(); + + return $this->getResponse(User::findOrFail($id)); + } + + /** + * Update the specified resource in storage. + * + * @param int $id + * + * @return Response + */ + public function update($id) + { + $this->checkParametersEmpty(); + + $attributes = array_get($this->getDocument(), 'data.attributes', []); + $attributes = array_filter($attributes, function ($value) { + return $value !== null; + }); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = [ + 'name' => 'sometimes|required|string|max:255', + 'email' => 'sometimes|required|email|max:255|unique:users', + 'password' => 'sometimes|required|string|min:6|max:255', + ]; + $validator = Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $author = User::findOrFail($id); + $author->fill($attributes); + $author->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return Response + */ + public function destroy($id) + { + $this->checkParametersEmpty(); + + $author = User::findOrFail($id); + $author->delete(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Get JWT for signed in user. + * + * @return string + */ + public function getSignedInUserJwt() + { + $this->checkParametersEmpty(); + + $currentUser = Auth::user(); + /** @var UserJwtCodecInterface $userCodec */ + $userCodec = app(UserJwtCodecInterface::class); + + return $userCodec->encode($currentUser); + } +} diff --git a/app/Http/Controllers/JsonApi/JsonApiController.php b/app/Http/Controllers/JsonApi/JsonApiController.php new file mode 100644 index 00000000000..907d23e0fb5 --- /dev/null +++ b/app/Http/Controllers/JsonApi/JsonApiController.php @@ -0,0 +1,58 @@ +integration = app(IntegrationInterface::class); + $this->initJsonApiSupport(); + } + + /* + * If you use Eloquent the following helper method will assist you to + * encode database collections and keep your code clean. + */ + + /** + * @param object|array $data + * @param int $statusCode + * @param array|null $links + * @param mixed $meta + * + * @return Response + */ + public function getResponse( + $data, + $statusCode = Response::HTTP_OK, + $links = null, + $meta = null + ) { + $data = ($data instanceof Collection ? $data->all() : $data); + return $this->getContentResponse($data, $statusCode, $links, $meta); + } + + /** + * Add URL prefix to document links. + */ + protected function getConfig() + { + $config = $this->traitGetConfig(); + $config[C::JSON][C::JSON_URL_PREFIX] = \Request::getSchemeAndHttpHost(); + + return $config; + } +} diff --git a/app/Http/Controllers/JsonApi/LaravelIntegration.php b/app/Http/Controllers/JsonApi/LaravelIntegration.php new file mode 100644 index 00000000000..8fabd265fbb --- /dev/null +++ b/app/Http/Controllers/JsonApi/LaravelIntegration.php @@ -0,0 +1,55 @@ +currentRequest === null) { + $this->currentRequest = app(Request::class); + } + + return $this->currentRequest; + } + + /** + * @inheritdoc + */ + public function declareSupportedExtensions(SupportedExtensionsInterface $extensions) + { + app()->instance(SupportedExtensionsInterface::class, $extensions); + } + + /** + * @inheritdoc + * + * @return Response + */ + public function createResponse($content, $statusCode, array $headers) + { + return new Response($content, $statusCode, $headers); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index ceea60a7a9e..6c4b577191e 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -1,11 +1,15 @@ - \App\Http\Middleware\Authenticate::class, + 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + + self::JSON_API_BASIC_AUTH => \App\Http\Middleware\JsonApiBasicAuth::class, + self::JSON_API_JWT_AUTH => \App\Http\Middleware\JsonApiJwtAuth::class, ]; } diff --git a/app/Http/Middleware/JsonApiBasicAuth.php b/app/Http/Middleware/JsonApiBasicAuth.php new file mode 100644 index 00000000000..38fcd2c29a5 --- /dev/null +++ b/app/Http/Middleware/JsonApiBasicAuth.php @@ -0,0 +1,35 @@ +attempt([ + 'email' => $login, + 'password' => $password, + ]); + + return $isAuthenticated; + }; + + /** @var Closure|null $authorizeClosure */ + $authorizeClosure = null; + + /** @var string|null $realm */ + $realm = null; + + parent::__construct($integration, $authenticateClosure, $authorizeClosure, $realm); + } +} diff --git a/app/Http/Middleware/JsonApiJwtAuth.php b/app/Http/Middleware/JsonApiJwtAuth.php new file mode 100644 index 00000000000..154c307f723 --- /dev/null +++ b/app/Http/Middleware/JsonApiJwtAuth.php @@ -0,0 +1,39 @@ +decode($jwt)) !== null) { + $auth->login($user); + $isAuthenticated = true; + } + + return $isAuthenticated; + }; + + /** @var Closure|null $authorizeClosure */ + $authorizeClosure = null; + + /** @var string|null $realm */ + $realm = null; + + parent::__construct($integration, $authenticateClosure, $authorizeClosure, $realm); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index a2c35414107..d847484782c 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -12,6 +12,6 @@ class VerifyCsrfToken extends BaseVerifier * @var array */ protected $except = [ - // + 'api/v1/*', ]; } diff --git a/app/Http/Routes/authors.php b/app/Http/Routes/authors.php new file mode 100644 index 00000000000..0476025f1d0 --- /dev/null +++ b/app/Http/Routes/authors.php @@ -0,0 +1,7 @@ + ['index', 'show', 'store', 'update', 'destroy']] +); diff --git a/app/Http/Routes/comments.php b/app/Http/Routes/comments.php new file mode 100644 index 00000000000..34753c762d9 --- /dev/null +++ b/app/Http/Routes/comments.php @@ -0,0 +1,7 @@ + ['index', 'show', 'store', 'update', 'destroy']] +); diff --git a/app/Http/Routes/posts.php b/app/Http/Routes/posts.php new file mode 100644 index 00000000000..7026771e8df --- /dev/null +++ b/app/Http/Routes/posts.php @@ -0,0 +1,7 @@ + ['index', 'show', 'store', 'update', 'destroy']] +); diff --git a/app/Http/Routes/sites.php b/app/Http/Routes/sites.php new file mode 100644 index 00000000000..a4b3cc7bb90 --- /dev/null +++ b/app/Http/Routes/sites.php @@ -0,0 +1,7 @@ + ['index', 'show', 'store', 'update', 'destroy']] +); diff --git a/app/Http/Routes/users.php b/app/Http/Routes/users.php new file mode 100644 index 00000000000..c1222953e1f --- /dev/null +++ b/app/Http/Routes/users.php @@ -0,0 +1,7 @@ + ['index', 'show', 'store', 'update', 'destroy']] +); diff --git a/app/Http/routes.php b/app/Http/routes.php index 1ad35497d06..cf8922ff5aa 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -1,5 +1,7 @@ Kernel::JSON_API_BASIC_AUTH, 'uses' => 'Demo\UsersController@getSignedInUserJwt'] +); + +Route::group([ + 'prefix' => 'api/v1', + 'middleware' => [ + Kernel::JSON_API_JWT_AUTH, // comment out this line if you want to disable authentication + ] +], function () { + + Route::get('login/refresh', 'Demo\UsersController@getSignedInUserJwt'); + + include __DIR__ . '/Routes/authors.php'; + include __DIR__ . '/Routes/comments.php'; + include __DIR__ . '/Routes/posts.php'; + include __DIR__ . '/Routes/sites.php'; + include __DIR__ . '/Routes/users.php'; + +}); diff --git a/app/Jwt/UserJwtCodec.php b/app/Jwt/UserJwtCodec.php new file mode 100644 index 00000000000..ee7a4781ec5 --- /dev/null +++ b/app/Jwt/UserJwtCodec.php @@ -0,0 +1,62 @@ + $issuedAt, + self::CLAIM_EXPIRATION_TIME => $issuedAt + self::EXPIRATION_PERIOD_IN_SECONDS, + self::CLAIM_USER_ID => $user->getAuthIdentifier(), + ]; + $jwt = JWT::encode($token, $this->getSigningKey(), self::SIGNING_ALGORITHM); + + return $jwt; + } + + /** + * @inheritdoc + */ + public function decode($jwt) + { + $payload = JWT::decode($jwt, $this->getSigningKey(), [self::SIGNING_ALGORITHM]); + $userId = isset($payload->{self::CLAIM_USER_ID}) === true ? $payload->{self::CLAIM_USER_ID} : null; + $user = $userId !== null ? User::find($userId) : null; + + return $user; + } + + /** + * @return string + */ + private function getSigningKey() + { + /** @var string $key */ + $key = env('APP_KEY'); + assert('$key !== null', 'Encryption key must be configured.'); + + return $key; + } +} diff --git a/app/Jwt/UserJwtCodecInterface.php b/app/Jwt/UserJwtCodecInterface.php new file mode 100644 index 00000000000..56b08ed70e0 --- /dev/null +++ b/app/Jwt/UserJwtCodecInterface.php @@ -0,0 +1,24 @@ +hasMany(Post::class); + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 00000000000..15a3ecd0bf9 --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,36 @@ +belongsTo(Post::class); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php new file mode 100644 index 00000000000..7cd9899f0bf --- /dev/null +++ b/app/Models/Post.php @@ -0,0 +1,64 @@ +belongsTo(Author::class); + } + + /** + * Get relation to site. + * + * @return BelongsTo + */ + public function site() + { + return $this->belongsTo(Site::class); + } + + /** + * Get relation to comments. + * + * @return HasMany + */ + public function comments() + { + return $this->hasMany(Comment::class); + } +} diff --git a/app/Models/Site.php b/app/Models/Site.php new file mode 100644 index 00000000000..db1914703e5 --- /dev/null +++ b/app/Models/Site.php @@ -0,0 +1,35 @@ +hasMany(Post::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 35471f6ff15..46dcc61e21b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,11 @@ namespace App\Providers; -use Illuminate\Support\ServiceProvider; +use \App\Jwt\UserJwtCodec; +use \App\Jwt\UserJwtCodecInterface; +use \Illuminate\Support\ServiceProvider; +use \App\Http\Controllers\JsonApi\LaravelIntegration; +use \Neomerx\Limoncello\Contracts\IntegrationInterface; class AppServiceProvider extends ServiceProvider { @@ -23,6 +27,7 @@ public function boot() */ public function register() { - // + $this->app->bind(IntegrationInterface::class, function () {return new LaravelIntegration();}); + $this->app->bind(UserJwtCodecInterface::class, function () {return new UserJwtCodec();}); } } diff --git a/app/Schemas/AuthorSchema.php b/app/Schemas/AuthorSchema.php new file mode 100644 index 00000000000..1698ea9e029 --- /dev/null +++ b/app/Schemas/AuthorSchema.php @@ -0,0 +1,50 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($author) + { + /** @var Author $author */ + return [ + 'first' => $author->first_name, + 'last' => $author->last_name, + 'twitter' => $author->twitter, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($author) + { + /** @var Author $author */ + return [ + 'posts' => [self::DATA => $author->posts->all()], + ]; + } +} diff --git a/app/Schemas/CommentSchema.php b/app/Schemas/CommentSchema.php new file mode 100644 index 00000000000..f1d0619078c --- /dev/null +++ b/app/Schemas/CommentSchema.php @@ -0,0 +1,58 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($comment) + { + /** @var Comment $comment */ + return [ + 'body' => $comment->body, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($comment) + { + /** @var Comment $comment */ + return [ + 'post' => [self::DATA => $comment->post], + ]; + } + + /** + * @inheritdoc + */ + public function getIncludePaths() + { + return [ + 'post', + ]; + } +} diff --git a/app/Schemas/PostSchema.php b/app/Schemas/PostSchema.php new file mode 100644 index 00000000000..c814f1338ce --- /dev/null +++ b/app/Schemas/PostSchema.php @@ -0,0 +1,50 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($post) + { + /** @var Post $post */ + return [ + 'title' => $post->title, + 'body' => $post->body, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($post) + { + /** @var Post $post */ + return [ + 'author' => [self::DATA => $post->author], + 'comments' => [self::DATA => $post->comments->all()], + ]; + } +} diff --git a/app/Schemas/SiteSchema.php b/app/Schemas/SiteSchema.php new file mode 100644 index 00000000000..c2ad68c885f --- /dev/null +++ b/app/Schemas/SiteSchema.php @@ -0,0 +1,60 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($site) + { + /** @var Site $site */ + return [ + 'name' => $site->name, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($site) + { + /** @var Site $site */ + return [ + 'posts' => [self::DATA => $site->posts->all()], + ]; + } + + /** + * @inheritdoc + */ + public function getIncludePaths() + { + return [ + 'posts', + 'posts.author', + 'posts.comments', + ]; + } +} diff --git a/app/Schemas/UserSchema.php b/app/Schemas/UserSchema.php new file mode 100644 index 00000000000..d1af8f9c4f2 --- /dev/null +++ b/app/Schemas/UserSchema.php @@ -0,0 +1,38 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($user) + { + /** @var User $user */ + return [ + 'name' => $user->name, + 'email' => $user->email, + ]; + } +} diff --git a/app/User.php b/app/User.php index 86eabed1fc4..e4921952610 100644 --- a/app/User.php +++ b/app/User.php @@ -2,12 +2,25 @@ namespace App; -use Illuminate\Auth\Authenticatable; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Auth\Passwords\CanResetPassword; -use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; -use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; +use \Hash; +use \Carbon\Carbon; +use \Illuminate\Auth\Authenticatable; +use \Illuminate\Database\Eloquent\Model; +use \Illuminate\Auth\Passwords\CanResetPassword; +use \Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; +use \Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; +/** + * @property int id + * @property string name + * @property string email + * @property string password + * @property Carbon created_at + * @property Carbon updated_at + * + * @method static User findOrFail(int $id) + * @method static null|User find(int $id) + */ class User extends Model implements AuthenticatableContract, CanResetPasswordContract { use Authenticatable, CanResetPassword; @@ -32,4 +45,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon * @var array */ protected $hidden = ['password', 'remember_token']; + + + /** + * Set password. The password would be hashed. + * + * @param string $value + */ + public function setPasswordAttribute($value) + { + $this->attributes['password'] = Hash::make((string)$value); + } } diff --git a/composer.json b/composer.json index a6ced5e2f92..38161f44f2b 100644 --- a/composer.json +++ b/composer.json @@ -1,25 +1,30 @@ { - "name": "laravel/laravel", - "description": "The Laravel Framework.", - "keywords": ["framework", "laravel"], + "name": "neomerx/limoncello-collins", + "description": "Quick start JSON API application", + "keywords": ["framework", "laravel", "neomerx", "limoncello", "json-api"], "license": "MIT", "type": "project", "require": { "php": ">=5.5.9", - "laravel/framework": "5.1.*" + "laravel/framework": "5.1.*", + "neomerx/limoncello": "^0.4.3", + "neomerx/cors-illuminate": "^0.2.0", + "firebase/php-jwt": "^3.0" }, "require-dev": { "fzaninotto/faker": "~1.4", "mockery/mockery": "0.9.*", "phpunit/phpunit": "~4.0", - "phpspec/phpspec": "~2.1" + "phpspec/phpspec": "~2.1", + "barryvdh/laravel-ide-helper": "^2.0" }, "autoload": { "classmap": [ "database" ], "psr-4": { - "App\\": "app/" + "App\\": "app/", + "DemoTests\\": "tests/Demo" } }, "autoload-dev": { @@ -30,7 +35,8 @@ "scripts": { "post-install-cmd": [ "php artisan clear-compiled", - "php artisan optimize" + "php artisan optimize", + "php artisan vendor:publish --provider='Neomerx\\CorsIlluminate\\Providers\\LaravelServiceProvider'" ], "pre-update-cmd": [ "php artisan clear-compiled" @@ -42,7 +48,8 @@ "php -r \"copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ - "php artisan key:generate" + "php artisan key:generate", + "php artisan vendor:publish --provider='Neomerx\\CorsIlluminate\\Providers\\LaravelServiceProvider'" ] }, "config": { diff --git a/config/app.php b/config/app.php index 15c75aa5862..ccd43ab6edf 100644 --- a/config/app.php +++ b/config/app.php @@ -136,6 +136,7 @@ Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, + \Neomerx\CorsIlluminate\Providers\LaravelServiceProvider::class, /* * Application Service Providers... diff --git a/config/cors-illuminate.php b/config/cors-illuminate.php new file mode 100644 index 00000000000..7a894320d13 --- /dev/null +++ b/config/cors-illuminate.php @@ -0,0 +1,97 @@ + [ + 'scheme' => 'http', + 'host' => 'localhost', + 'port' => 8888, + ], + + /** + * A list of allowed request origins (lower-cased, no trail slashes). + * Value `true` enables and value `null` disables origin. + * If value is not on the list it is considered as not allowed. + * Environment variables could be used for enabling/disabling certain hosts. + */ + Settings::KEY_ALLOWED_ORIGINS => [ + 'http://localhost' => null, + 'http://some.disabled.com' => null, + '*' => null, + ], + + /** + * A list of allowed request methods (case sensitive). Value `true` enables and value `null` disables method. + * If value is not on the list it is considered as not allowed. + * Environment variables could be used for enabling/disabling certain methods. + * + * Security Note: you have to remember CORS is not access control system and you should not expect all cross-origin + * requests will have pre-flights. For so-called 'simple' methods with so-called 'simple' headers request + * will be made without pre-flight. Thus you can not restrict such requests with CORS and should use other means. + * For example method 'GET' without any headers or with only 'simple' headers will not have pre-flight request so + * disabling it will not restrict access to resource(s). + * + * You can read more on 'simple' methods at http://www.w3.org/TR/cors/#simple-method + */ + Settings::KEY_ALLOWED_METHODS => [ + 'GET' => true, + 'PATCH' => true, + 'POST' => true, + 'PUT' => true, + 'DELETE' => true, + ], + + /** + * A list of allowed request headers (lower-cased). Value `true` enables and value `null` disables header. + * If value is not on the list it is considered as not allowed. + * Environment variables could be used for enabling/disabling certain headers. + * + * Security Note: you have to remember CORS is not access control system and you should not expect all cross-origin + * requests will have pre-flights. For so-called 'simple' methods with so-called 'simple' headers request + * will be made without pre-flight. Thus you can not restrict such requests with CORS and should use other means. + * For example method 'GET' without any headers or with only 'simple' headers will not have pre-flight request so + * disabling it will not restrict access to resource(s). + * + * You can read more on 'simple' headers at http://www.w3.org/TR/cors/#simple-header + */ + Settings::KEY_ALLOWED_HEADERS => [ + 'accept' => null, + 'content-type' => null, + 'x-custom-request-header' => null, + ], + + /** + * A list of headers (case insensitive) which will be made accessible to user agent (browser) in response. + * Value `true` enables and value `null` disables header. + * If value is not on the list it is considered as not allowed. + * Environment variables could be used for enabling/disabling certain headers. + * + * For example, + * + * public static $exposedHeaders = [ + * 'content-type' => true, + * 'x-custom-response-header' => null, + * ]; + */ + Settings::KEY_EXPOSED_HEADERS => [ + 'content-type' => null, + 'x-custom-response-header' => null, + ], + + /** + * If access with credentials is supported by the resource. + */ + Settings::KEY_IS_USING_CREDENTIALS => false, + + /** + * Pre-flight response cache max period in seconds. + */ + Settings::KEY_PRE_FLIGHT_MAX_AGE => 0, + +]; diff --git a/config/limoncello.php b/config/limoncello.php new file mode 100644 index 00000000000..0d92f98333a --- /dev/null +++ b/config/limoncello.php @@ -0,0 +1,57 @@ + [ + Author::class => AuthorSchema::class, + Comment::class => CommentSchema::class, + Post::class => PostSchema::class, + Site::class => SiteSchema::class, + User::class => UserSchema::class, + ], + + /* + |-------------------------------------------------------------------------- + | JSON encoding options + |-------------------------------------------------------------------------- + | + | Here you can specify options to be used while converting data to actual + | JSON representation with json_encode function. + | + | For example if options are set to JSON_PRETTY_PRINT then returned data + | will be nicely formatted with spaces. + | + | see http://php.net/manual/en/function.json-encode.php + | + | If this section is omitted default values will be used. + | + */ + C::JSON => [ + C::JSON_OPTIONS => JSON_PRETTY_PRINT, + C::JSON_DEPTH => C::JSON_DEPTH_DEFAULT, + ] + +]; \ No newline at end of file diff --git a/database/migrations/0000_00_01_000000_create_authors_table.php b/database/migrations/0000_00_01_000000_create_authors_table.php new file mode 100644 index 00000000000..8ffe59e0c0d --- /dev/null +++ b/database/migrations/0000_00_01_000000_create_authors_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->string('first_name'); + $table->string('last_name'); + /** @noinspection PhpUndefinedMethodInspection */ + $table->string('twitter')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('authors'); + } +} diff --git a/database/migrations/0000_00_02_000000_create_sites_table.php b/database/migrations/0000_00_02_000000_create_sites_table.php new file mode 100644 index 00000000000..e20841d3d1d --- /dev/null +++ b/database/migrations/0000_00_02_000000_create_sites_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('sites'); + } +} diff --git a/database/migrations/0000_00_03_000000_create_posts_table.php b/database/migrations/0000_00_03_000000_create_posts_table.php new file mode 100644 index 00000000000..d73a6f7b6e7 --- /dev/null +++ b/database/migrations/0000_00_03_000000_create_posts_table.php @@ -0,0 +1,38 @@ +increments('id'); + $table->unsignedInteger('author_id'); + $table->unsignedInteger('site_id'); + $table->string('title'); + $table->text('body'); + $table->timestamps(); + + $table->foreign('author_id')->references('id')->on('authors')->onDelete('cascade'); + $table->foreign('site_id')->references('id')->on('sites')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('posts'); + } +} diff --git a/database/migrations/0000_00_04_000000_create_comments_table.php b/database/migrations/0000_00_04_000000_create_comments_table.php new file mode 100644 index 00000000000..6103a7fb53b --- /dev/null +++ b/database/migrations/0000_00_04_000000_create_comments_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->unsignedInteger('post_id'); + $table->string('body'); + $table->timestamps(); + + $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('comments'); + } +} diff --git a/database/seeds/AuthorsTableSeeder.php b/database/seeds/AuthorsTableSeeder.php new file mode 100644 index 00000000000..72c28c5b1e5 --- /dev/null +++ b/database/seeds/AuthorsTableSeeder.php @@ -0,0 +1,21 @@ +first_name = 'Dan'; + $author->last_name = 'Gebhardt'; + $author->twitter = 'dgeb'; + $author->save(); + } +} diff --git a/database/seeds/CommentsTableSeeder.php b/database/seeds/CommentsTableSeeder.php new file mode 100644 index 00000000000..cea17e3453d --- /dev/null +++ b/database/seeds/CommentsTableSeeder.php @@ -0,0 +1,29 @@ +body = 'First!'; + $comment->post_id = $post->id; + $comment->save(); + + $comment = new Comment(); + $comment->body = 'I like XML better'; + $comment->post_id = $post->id; + $comment->save(); + } +} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index 988ea210051..d1309a1f867 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -14,7 +14,12 @@ public function run() { Model::unguard(); - // $this->call(UserTableSeeder::class); + $this->call(SitesTableSeeder::class); + $this->call(AuthorsTableSeeder::class); + $this->call(PostsTableSeeder::class); + $this->call(CommentsTableSeeder::class); + + $this->call(UsersTableSeeder::class); Model::reguard(); } diff --git a/database/seeds/PostsTableSeeder.php b/database/seeds/PostsTableSeeder.php new file mode 100644 index 00000000000..4b6df2604a9 --- /dev/null +++ b/database/seeds/PostsTableSeeder.php @@ -0,0 +1,32 @@ +title = 'JSON API paints my bikeshed!'; + $post->body = 'If you\'ve ever argued with your team about the way your JSON responses should be '. + 'formatted, JSON API is your anti-bikeshedding weapon.'; + + /** @var Site $site */ + $site = Site::firstOrFail(); + /** @var Author $author */ + $author = Author::firstOrFail(); + + $post->site_id = $site->id; + $post->author_id = $author->id; + + $post->save(); + } +} diff --git a/database/seeds/SitesTableSeeder.php b/database/seeds/SitesTableSeeder.php new file mode 100644 index 00000000000..82e775c187b --- /dev/null +++ b/database/seeds/SitesTableSeeder.php @@ -0,0 +1,19 @@ +name = 'JSON API Samples'; + $site->save(); + } +} diff --git a/database/seeds/UsersTableSeeder.php b/database/seeds/UsersTableSeeder.php new file mode 100644 index 00000000000..33de9dd165d --- /dev/null +++ b/database/seeds/UsersTableSeeder.php @@ -0,0 +1,27 @@ + 'John Dow', + 'email' => self::SAMPLE_LOGIN, + 'password' => self::SAMPLE_PASSWORD, + ]))->save(); + } +} diff --git a/readme.md b/readme.md index f67a6cf7cef..804c0e7cc52 100644 --- a/readme.md +++ b/readme.md @@ -1,27 +1,179 @@ -## Laravel PHP Framework +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/neomerx/json-api/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/neomerx/json-api/?branch=master) +[![Code Coverage](https://scrutinizer-ci.com/g/neomerx/json-api/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/neomerx/json-api/?branch=master) +[![Build Status](https://travis-ci.org/neomerx/json-api.svg?branch=master)](https://travis-ci.org/neomerx/json-api) +[![HHVM](https://img.shields.io/hhvm/neomerx/json-api.svg)](https://travis-ci.org/neomerx/json-api) +[![License](https://poser.pugx.org/neomerx/limoncello-collins/license.svg)](https://packagist.org/packages/neomerx/limoncello-collins) -[![Build Status](https://travis-ci.org/laravel/framework.svg)](https://travis-ci.org/laravel/framework) -[![Total Downloads](https://poser.pugx.org/laravel/framework/d/total.svg)](https://packagist.org/packages/laravel/framework) -[![Latest Stable Version](https://poser.pugx.org/laravel/framework/v/stable.svg)](https://packagist.org/packages/laravel/framework) -[![Latest Unstable Version](https://poser.pugx.org/laravel/framework/v/unstable.svg)](https://packagist.org/packages/laravel/framework) -[![License](https://poser.pugx.org/laravel/framework/license.svg)](https://packagist.org/packages/laravel/framework) +## Quick start JSON API application -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable, creative experience to be truly fulfilling. Laravel attempts to take the pain out of development by easing common tasks used in the majority of web projects, such as authentication, routing, sessions, queueing, and caching. +Limoncello Collins is a [JSON API](http://jsonapi.org/) quick start application. + +Technically it is a default [Laravel 5.1 LTS](https://github.com/laravel/laravel) application integrated with +- [JSON API implementation](https://github.com/neomerx/json-api) +- JWT, Bearer and Basic Authentication +- Cross-Origin Resource Sharing (CORS) -Laravel is accessible, yet powerful, providing powerful tools needed for large, robust applications. A superb inversion of control container, expressive migration system, and tightly integrated unit testing support give you the tools you need to build any application with which you are tasked. +It could be a great start if you are planning to develop API with Laravel. + +### In a nutshell -## Official Documentation +It incredibly reduces complexity of protocol implementation so you can focus on the core code. For example a minimal controller to handle ```GET resource``` requests might look as simple as this -Documentation for the framework can be found on the [Laravel website](http://laravel.com/docs). +```php +class AuthorsController extends Controller +{ + public function show($id) + { + return $this->getResponse(Author::findOrFail($id)); + } +} +``` -## Contributing +It has support for -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](http://laravel.com/docs/contributions). +* Request validation +* Encoding result to JSON API format +* Filling in all required Response headers +* Error handling +* Basic Authentication -## Security Vulnerabilities +This service can be called with ```curl``` and it will return response in JSON API format with correct headers -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell at taylor@laravel.com. All security vulnerabilities will be promptly addressed. +``` +HTTP/1.1 200 OK +Content-Type: application/vnd.api+json +Host: localhost:8888 +``` + +and body formatted + +```json +{ + "data": { + "attributes": { + "first_name": "Dan", + "last_name": "Gebhardt" + }, + "id": "1", + "links": { + "posts": { + "linkage": { + "id": "1", + "type": "posts" + } + }, + "self": "http://localhost:8888/authors/1" + }, + "type": "authors" + } +} +``` + +Limoncello Collins ships with sample CRUD API for models ```Author```, ```Comment```, ```Post```, ```Site``` and ```User```. Main code locations are + +* ```app/Models``` +* ```app/Schemas``` +* ```app/Http/Controllers/Demo``` +* ```app/Http/Controllers/JsonApi``` +* ```app/Http/Routes``` +* ```config/limoncello.php``` + +### Installation + +#### Download latest version + +``` +$ composer create-project neomerx/limoncello-collins --prefer-dist +$ cd limoncello-collins/ +``` + +#### Migrate and seed database + +For simplicity it uses sqlite database by default. You are free to change database settings before the next step. + +In case of sqlite usage you need to create an empty database file + +``` +$ touch storage/database.sqlite +``` + +Migrate and seed data + +``` +$ php artisan migrate --force && php artisan db:seed --force +``` + +#### Run HTTP server + +An easy way to start development server is + +``` +$ php artisan serve --port=8888 +``` + +And that's it! The server can serve JSON API. + +As it has Authentication enabled a security token (JWT) should be received first + +``` +curl -u user@example.com:password 'http://localhost:8888/login/basic' +``` + +it should return JWT (looks like long random string). API should be called with this token + +``` +curl -X GET -H "Authorization: Bearer " 'http://localhost:8888/api/v1/authors' +``` + +This command should return JSON-API document with a list of authors. + +> Authentication middleware for API could be disabled in `app/Http/routes.php` + +### Adding a new service + +Let's assume that you have an Eloquent model and want to add CRUD API for it. You should + +* Add application routes +* Add controller +* Add code for input validation and CRUD +* Return response +* Add model schema (details below) + +#### Application routes + +Routes are added the same way as in Laravel. It's recommended to read [CRUD section of the specification](http://jsonapi.org/format/#crud) to be aware which HTTP verbs should be used. + +#### Controller + +Your Controller should extend/inherit Controller with JSON API supported functions added. In ```app/Http/Controllers/Controller.php``` you can see it take only a few lines of code to add such support to any controller. + +The Controller can now + +- Parse and validate Request parameters and headers. +- Match decoders for input data based on the data type defined in ```Content-Type``` header and encoders based on the ```Accept``` header. +- Compose JSON API Responses + +**To find out more, please check out the [Wiki](https://github.com/neomerx/limoncello/wiki)** + +#### Model schema + +Model schema tells encoder how to convert object/model to JSON API format. It defines what fields (attributes and relationships) should be converted and how. How relationships and urls should be shown and what objects should be placed to ```included``` section. Fore more information see [neomerx/json-api](https://github.com/neomerx/json-api). + +Schemas are placed in ```app/Schemas``` folder. When a new schema is added a mapping between model and its schema should be added to ```config/limoncello.php``` configuration file. + +### Error handling + +If an exception is thrown during the process of handling HTTP request it will be converted to HTTP response with certain status code. The application already has support for a few common exceptions and you can add more. Exceptions could be converted to both HTTP code only responses and response containing JSON API Error objects. Please see ```app/Exceptions/Handler.php``` for examples of both. + + +## Questions? + +[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/neomerx/json-api) ### License -The Laravel framework is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT) +This project is a fork from upstream [laravel/laravel](https://github.com/laravel/laravel). All changes to the upstream are licensed under the [MIT license](http://opensource.org/licenses/MIT) + +## Versioning + +This project is synchronized with upstream ```master``` branch and uses the same version numbers. diff --git a/tests/Demo/Api/AuthorsTest.php b/tests/Demo/Api/AuthorsTest.php new file mode 100644 index 00000000000..408024d4151 --- /dev/null +++ b/tests/Demo/Api/AuthorsTest.php @@ -0,0 +1,77 @@ +callGet(self::API_URL); + $this->assertResponseOk(); + $this->assertNotEmpty($collection = json_decode($response->getContent())); + foreach ($collection->data as $author) { + $response = $this->callGet(self::API_URL . $author->id); + $this->assertResponseOk(); + $this->assertNotNull($item = json_decode($response->getContent())); + $this->assertEquals($author->id, $item->data->id); + } + } + + /** + * Test store, update and delete. + */ + public function testStoreUpdateAndDelete() + { + $requestBody = <<callPost(self::API_URL, $requestBody); + $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); + $this->assertNotNull($author = json_decode($response->getContent())->data); + $this->assertNotEmpty($author->id); + $this->assertNotEmpty($response->headers->get('Location')); + + // re-read and check + $this->assertNotNull($author = json_decode($this->callGet(self::API_URL . $author->id)->getContent())->data); + $this->assertEquals('John', $author->attributes->first); + + // Update + $requestBody = "{ + \"data\" : { + \"type\" : \"authors\", + \"id\" : \"$author->id\", + \"attributes\" : { + \"first\" : \"Jane\" + } + } + }"; + $response = $this->callPatch(self::API_URL . $author->id, $requestBody); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + + // re-read and check + $this->assertNotNull($author = json_decode($this->callGet(self::API_URL . $author->id)->getContent())->data); + $this->assertEquals('Jane', $author->attributes->first); + + // Delete + $response = $this->callDelete(self::API_URL . $author->id); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + } +} diff --git a/tests/Demo/Api/CommentsTest.php b/tests/Demo/Api/CommentsTest.php new file mode 100644 index 00000000000..640ef89943f --- /dev/null +++ b/tests/Demo/Api/CommentsTest.php @@ -0,0 +1,91 @@ + ['1', '2'], + ]; + + $parameters = [ + ParametersParserInterface::PARAM_FILTER => $filter, + ]; + + /** @var Response $response */ + $response = $this->callGet(self::API_URL, $parameters); + $this->assertResponseOk(); + $this->assertNotEmpty($collection = json_decode($response->getContent())); + foreach ($collection->data as $comment) { + $response = $this->callGet(self::API_URL . $comment->id); + $this->assertResponseOk(); + $this->assertNotNull($item = json_decode($response->getContent())); + $this->assertEquals($comment->id, $item->data->id); + } + } + + /** + * Test store, update and delete. + */ + public function testStoreUpdateAndDelete() + { + // assume post with id = 1 exists + + $requestBody = <<callPost(self::API_URL, $requestBody); + $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); + $this->assertNotNull($comment = json_decode($response->getContent())->data); + $this->assertNotEmpty($comment->id); + $this->assertNotEmpty($response->headers->get('Location')); + + // re-read and check + $this->assertNotNull($comment = json_decode($this->callGet(self::API_URL . $comment->id)->getContent())->data); + $this->assertEquals('comment', $comment->attributes->body); + + // Update + $requestBody = "{ + \"data\" : { + \"type\" : \"comments\", + \"id\" : \"$comment->id\", + \"attributes\" : { + \"body\" : \"new comment\" + } + } + }"; + $response = $this->callPatch(self::API_URL . $comment->id, $requestBody); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + + // re-read and check + $this->assertNotNull($comment = json_decode($this->callGet(self::API_URL . $comment->id)->getContent())->data); + $this->assertEquals('new comment', $comment->attributes->body); + + // Delete + $response = $this->callDelete(self::API_URL . $comment->id); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + } +} diff --git a/tests/Demo/Api/PostsTest.php b/tests/Demo/Api/PostsTest.php new file mode 100644 index 00000000000..c9cc58044e5 --- /dev/null +++ b/tests/Demo/Api/PostsTest.php @@ -0,0 +1,86 @@ +callGet(self::API_URL); + $this->assertResponseOk(); + $this->assertNotEmpty($collection = json_decode($response->getContent())); + foreach ($collection->data as $post) { + $response = $this->callGet(self::API_URL . $post->id); + $this->assertResponseOk(); + $this->assertNotNull($item = json_decode($response->getContent())); + $this->assertEquals($post->id, $item->data->id); + } + } + + /** + * Test store, update and delete. + */ + public function testStoreUpdateAndDelete() + { + // assume author and site with ids = 1 exist + + $requestBody = <<callPost(self::API_URL, $requestBody); + $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); + $this->assertNotNull($post = json_decode($response->getContent())->data); + $this->assertNotEmpty($post->id); + $this->assertNotEmpty($response->headers->get('Location')); + + // re-read and check + $this->assertNotNull($post = json_decode($this->callGet(self::API_URL . $post->id)->getContent())->data); + $this->assertEquals('post body', $post->attributes->body); + + // Update + $requestBody = "{ + \"data\" : { + \"type\" : \"posts\", + \"id\" : \"$post->id\", + \"attributes\" : { + \"body\" : \"new body\" + } + } + }"; + $response = $this->callPatch(self::API_URL . $post->id, $requestBody); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + + // re-read and check + $this->assertNotNull($post = json_decode($this->callGet(self::API_URL . $post->id)->getContent())->data); + $this->assertEquals('new body', $post->attributes->body); + + // Delete + $response = $this->callDelete(self::API_URL . $post->id); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + } +} diff --git a/tests/Demo/Api/SitesTest.php b/tests/Demo/Api/SitesTest.php new file mode 100644 index 00000000000..77b2da00824 --- /dev/null +++ b/tests/Demo/Api/SitesTest.php @@ -0,0 +1,75 @@ +callGet(self::API_URL); + $this->assertResponseOk(); + $this->assertNotEmpty($collection = json_decode($response->getContent())); + foreach ($collection->data as $site) { + $response = $this->callGet(self::API_URL . $site->id); + $this->assertResponseOk(); + $this->assertNotNull($item = json_decode($response->getContent())); + $this->assertEquals($site->id, $item->data->id); + } + } + + /** + * Test store, update and delete. + */ + public function testStoreUpdateAndDelete() + { + $requestBody = <<callPost(self::API_URL, $requestBody); + $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); + $this->assertNotNull($site = json_decode($response->getContent())->data); + $this->assertNotEmpty($site->id); + $this->assertNotEmpty($response->headers->get('Location')); + + // re-read and check + $this->assertNotNull($site = json_decode($this->callGet(self::API_URL . $site->id)->getContent())->data); + $this->assertEquals('Samples', $site->attributes->name); + + // Update + $requestBody = "{ + \"data\" : { + \"type\" : \"sites\", + \"id\" : \"$site->id\", + \"attributes\" : { + \"name\" : \"New name\" + } + } + }"; + $response = $this->callPatch(self::API_URL . $site->id, $requestBody); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + + // re-read and check + $this->assertNotNull($site = json_decode($this->callGet(self::API_URL . $site->id)->getContent())->data); + $this->assertEquals('New name', $site->attributes->name); + + // Delete + $response = $this->callDelete(self::API_URL . $site->id); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + } +} diff --git a/tests/Demo/Api/UsersTest.php b/tests/Demo/Api/UsersTest.php new file mode 100644 index 00000000000..b098149cedb --- /dev/null +++ b/tests/Demo/Api/UsersTest.php @@ -0,0 +1,77 @@ +callGet(self::API_URL); + $this->assertResponseOk(); + $this->assertNotEmpty($collection = json_decode($response->getContent())); + foreach ($collection->data as $user) { + $response = $this->callGet(self::API_URL . $user->id); + $this->assertResponseOk(); + $this->assertNotNull($item = json_decode($response->getContent())); + $this->assertEquals($user->id, $item->data->id); + } + } + + /** + * Test store, update and delete. + */ + public function testStoreUpdateAndDelete() + { + $requestBody = <<callPost(self::API_URL, $requestBody); + $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); + $this->assertNotNull($user = json_decode($response->getContent())->data); + $this->assertNotEmpty($user->id); + $this->assertNotEmpty($response->headers->get('Location')); + + // re-read and check + $this->assertNotNull($user = json_decode($this->callGet(self::API_URL . $user->id)->getContent())->data); + $this->assertEquals('John Dow', $user->attributes->name); + + // Update + $requestBody = "{ + \"data\" : { + \"type\" : \"users\", + \"id\" : \"$user->id\", + \"attributes\" : { + \"name\" : \"Jane\" + } + } + }"; + $response = $this->callPatch(self::API_URL . $user->id, $requestBody); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + + // re-read and check + $this->assertNotNull($user = json_decode($this->callGet(self::API_URL . $user->id)->getContent())->data); + $this->assertEquals('Jane', $user->attributes->name); + + // Delete + $response = $this->callDelete(self::API_URL . $user->id); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + } +} diff --git a/tests/Demo/BaseTestCase.php b/tests/Demo/BaseTestCase.php new file mode 100644 index 00000000000..8b4ee3e84e4 --- /dev/null +++ b/tests/Demo/BaseTestCase.php @@ -0,0 +1,109 @@ +call('GET', $url, $parameters, [], [], $this->getServerArray()); + } + + /** + * @param string $url + * + * @return Response + */ + protected function callDelete($url) + { + return $this->call('DELETE', $url, [], [], [], $this->getServerArray()); + } + + /** + * @param string $url + * @param string $content + * + * @return Response + */ + protected function callPost($url, $content) + { + return $this->call('POST', $url, [], [], [], $this->getServerArray(), $content); + } + + /** + * @param string $url + * @param string $content + * + * @return Response + */ + protected function callPatch($url, $content) + { + return $this->call('PATCH', $url, [], [], [], $this->getServerArray(), $content); + } + + /** + * @return array + */ + private function getServerArray() + { + $server = [ + 'CONTENT_TYPE' => 'application/vnd.api+json' + ]; + + // required for csrf_token() + \Session::start(); + + $basicAuth = $this->getBasicAuthHeader(); + $this->assertNotEmpty($basicAuth); + + $jwtAuth = $this->getJwtAuthHeader(); + $this->assertNotEmpty($jwtAuth); + + // Here you can choose what auth will be used for testing (basic or jwt) + $auth = $jwtAuth; + $headers = [ + 'CONTENT-TYPE' => 'application/vnd.api+json', + 'ACCEPT' => 'application/vnd.api+json', + 'Authorization' => $auth, + 'X-Requested-With' => 'XMLHttpRequest', + 'X-CSRF-TOKEN' => csrf_token(), + ]; + foreach ($headers as $key => $value) { + $server['HTTP_' . $key] = $value; + } + + return $server; + } + + /** + * @return string + */ + private function getBasicAuthHeader() + { + return 'Basic ' . base64_encode(UsersTableSeeder::SAMPLE_LOGIN . ':' . UsersTableSeeder::SAMPLE_PASSWORD); + } + + private function getJwtAuthHeader() + { + $allUsers = User::all(); + $this->assertGreaterThan(0, count($allUsers)); + /** @var User $firstUser */ + $this->assertNotNull($firstUser = $allUsers[0]); + + /** @var UserJwtCodecInterface $jwtCodec */ + $this->assertNotNull($jwtCodec = app(UserJwtCodecInterface::class)); + $jwt = $jwtCodec->encode($firstUser); + + return 'Bearer ' . $jwt; + } +} diff --git a/tests/Demo/Errors/ExceptionHanlderTest.php b/tests/Demo/Errors/ExceptionHanlderTest.php new file mode 100644 index 00000000000..98723614fb8 --- /dev/null +++ b/tests/Demo/Errors/ExceptionHanlderTest.php @@ -0,0 +1,90 @@ +callGet('/not-existing-path'); + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertEmpty($response->getContent()); + } + + /** + * Test 404 error. It actually tests custom error rendering which is set up for 'model not found' exceptions. + */ + public function test404ForModels() + { + /** @var Response $response */ + $response = $this->callGet(self::API_URL_PREFIX . 'sites/999999999999'); + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertNotEmpty($response->getContent()); + } + + /** + * Test supported extensions are added to response. + */ + public function testSupportedExtensionsPresent() + { + // send invalid name + $requestBody = <<callPost(self::API_URL_PREFIX . 'authors', $requestBody); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertEquals( + 'application/vnd.api+json;supported-ext="ext1,ex3"', + $response->headers->get('Content-Type') + ); + + $this->assertNotEmpty(json_decode($response->getContent())->errors); + } + + /** + * Test render for TooManyRequestsHttpException. + */ + public function testTooManyRequestsRender() + { + // Preparation + + /** @var Handler $handler */ + $this->assertNotNull($handler = app(Handler::class)); + + $retryAfterSeconds = 12; + $message = 'Hold On For a Second'; + $exception = new TooManyRequestsHttpException($retryAfterSeconds, $message); + $request = new Request(); + + // Test + $this->assertNotNull($response = $handler->render($request, $exception)); + + // Check + $errors = json_decode($response->getContent())->{DocumentInterface::KEYWORD_ERRORS}; + $this->assertCount(1, $errors); + $this->assertNotNull($error = $errors[0]); + $this->assertEquals($message, $error->{DocumentInterface::KEYWORD_ERRORS_DETAIL}); + + $this->assertEquals($retryAfterSeconds, $response->headers->get('Retry-After')); + } +} diff --git a/tests/Demo/Jwt/UserJwtCodecTest.php b/tests/Demo/Jwt/UserJwtCodecTest.php new file mode 100644 index 00000000000..b60b44a41cc --- /dev/null +++ b/tests/Demo/Jwt/UserJwtCodecTest.php @@ -0,0 +1,41 @@ +assertNotNull($this->codec = app(UserJwtCodecInterface::class)); + } + + /** + * Test User encode to JWT and decode from JWT back. + */ + public function testUserEncodeDecode() + { + $users = User::all(); + $this->assertGreaterThan(0, count($users)); + + /** @var User $firstUser */ + $this->assertNotNull($firstUser = $users[0]); + unset($users); + + $this->assertNotNull($jwt = $this->codec->encode($firstUser)); + $this->assertNotNull($sameUser = $this->codec->decode($jwt)); + + $this->assertEquals($firstUser->getAuthIdentifier(), $sameUser->getAuthIdentifier()); + } +} diff --git a/tests/Demo/Models/ModelsAndSeedsTest.php b/tests/Demo/Models/ModelsAndSeedsTest.php new file mode 100644 index 00000000000..d99e0ff3044 --- /dev/null +++ b/tests/Demo/Models/ModelsAndSeedsTest.php @@ -0,0 +1,71 @@ +assertNotEmpty(Author::all(), $message); + $this->assertNotEmpty(Comment::all(), $message); + $this->assertNotEmpty(Post::all(), $message); + $this->assertNotEmpty(Site::all(), $message); + + $isAuthenticated = Auth::attempt([ + 'email' => UsersTableSeeder::SAMPLE_LOGIN, + 'password' => UsersTableSeeder::SAMPLE_PASSWORD, + ]); + $this->assertTrue($isAuthenticated); + } + + /** + * Check models have proper relations with each other. + */ + public function testModelRelations() + { + /** @var Site $site */ + /** @noinspection PhpUndefinedMethodInspection */ + $this->assertNotNull($site = Site::firstOrFail()); + + $this->assertNotEmpty($site->posts); + /** @var Post $post */ + $post = null; + foreach ($site->posts as $curPost) { + /** @var Post $curPost */ + $this->assertNotNull($curPost->site); + if ($curPost->site_id === $site->id) { + $post = $curPost; + break; + } + } + $this->assertNotNull($post); + $this->assertNotNull($post->site); + $this->assertEquals($site->id, $post->site_id); + + /** @var Author $author */ + $this->assertNotNull($author = $post->author); + $this->assertNotEmpty($author->posts); + foreach ($author->posts as $curPost) { + /** @var Post $curPost */ + $this->assertNotNull($curPost->author); + } + + $this->assertNotEmpty($post->comments); + foreach ($post->comments as $curComment) { + /** @var Comment $curComment */ + $this->assertNotNull($curComment); + $this->assertEquals($post->id, $curComment->post_id); + } + } +}