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..dbb4864ca19 --- /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 database/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..9d440bc5f9f 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -1,51 +1,254 @@ -integration = new LaravelIntegration(); + $this->renderContainer = new RendererContainer($this->integration); + + $this->registerCustomExceptions(); + } + /** * A list of the exception types that should not be reported. * * @var array */ protected $dontReport = [ - HttpException::class, + ExpiredException::class, + GoneHttpException::class, + ValidationException::class, + ConflictHttpException::class, + NotFoundHttpException::class, ModelNotFoundException::class, + BadRequestHttpException::class, + UnexpectedValueException::class, + AccessDeniedHttpException::class, + SignatureInvalidException::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); + $request ?: null; + + $render = $this->renderContainer->getRenderer(get_class($exception)); + + if (($supportedExtensions = $this->getSupportedExtensions()) !== null) { + $render->withSupportedExtensions($supportedExtensions); + } + + $corsHeaders = $this->mergeCorsHeadersTo(); + $mediaType = new MediaType(MediaType::JSON_API_TYPE, MediaType::JSON_API_SUB_TYPE); + + return $render->withHeaders($corsHeaders)->withMediaType($mediaType)->render($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, + ExpiredException::class => Response::HTTP_UNAUTHORIZED, + SignatureInvalidException::class => Response::HTTP_UNAUTHORIZED, + 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->registerRenderer(ModelNotFoundException::class, $custom404render); + $this->renderContainer->registerRenderer(ValidationException::class, $customValidationRender); + $this->renderContainer->registerRenderer(TooManyRequestsHttpException::class, $customTooManyRequestsRender); + } + + /** + * @return RendererInterface + */ + private function getCustom404Render() + { + $converter = function (ModelNotFoundException $exception) { + $exception ?: null; + + // Prepare Error object (e.g. take info from the exception) + $title = 'Requested item not found'; + $error = new Error(null, null, null, null, $title); + + return $error; + }; + + $renderer = $this->renderContainer->createConvertContentRenderer(Response::HTTP_NOT_FOUND, $converter); + + return $renderer; + } + + /** + * @return RendererInterface + */ + private function getCustomValidationRender() + { + $converter = function (ValidationException $exception) { + // 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); + } + + return $errors; + }; + + $renderer = $this->renderContainer->createConvertContentRenderer(Response::HTTP_BAD_REQUEST, $converter); + + return $renderer; + } + + /** + * @return RendererInterface + */ + private function getCustomTooManyRequestsRender() + { + /** @var RendererInterface $renderer */ + $renderer = null; + $converter = function (TooManyRequestsHttpException $exception) use (&$renderer) { + // Prepare Error object (e.g. take info from the exception) + $title = 'Validation fails'; + $message = $exception->getMessage(); + $error = new Error(null, null, null, null, $title, $message); + + $headers = $exception->getHeaders(); + $renderer->withHeaders($headers); + + return $error; + }; + + $renderer = $this->renderContainer->createConvertContentRenderer(Response::HTTP_TOO_MANY_REQUESTS, $converter); + + return $renderer; + } + + /** + * @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; + } + + /** + * @return SupportedExtensionsInterface|null + */ + private function getSupportedExtensions() + { + /** @var SupportedExtensionsInterface|null $supportedExtensions */ + $supportedExtensions = app()->resolved(SupportedExtensionsInterface::class) === false ? null : + app()->make(SupportedExtensionsInterface::class); + + return $supportedExtensions; } } diff --git a/app/Http/Controllers/Demo/AuthorsController.php b/app/Http/Controllers/Demo/AuthorsController.php new file mode 100644 index 00000000000..3f13e0f74f3 --- /dev/null +++ b/app/Http/Controllers/Demo/AuthorsController.php @@ -0,0 +1,137 @@ +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']; + /** @noinspection PhpUndefinedClassInspection */ + $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 $idx + * + * @return Response + */ + public function show($idx) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Author::findOrFail($idx)); + } + + /** + * Update the specified resource in storage. + * + * @param int $idx + * + * @return Response + */ + public function update($idx) + { + $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']; + /** @noinspection PhpUndefinedClassInspection */ + $validator = \Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $author = Author::findOrFail($idx); + $author->fill($attributes); + $author->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $idx + * + * @return Response + */ + public function destroy($idx) + { + $this->checkParametersEmpty(); + + $author = Author::findOrFail($idx); + $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..d66e8a97727 --- /dev/null +++ b/app/Http/Controllers/Demo/CommentsController.php @@ -0,0 +1,125 @@ +getParameters()->getFilteringParameters()['ids']; + $idxs ?: 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.relationships.post.data.id', null); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = ['body' => 'required', 'post_id' => 'required|integer']; + /** @noinspection PhpUndefinedClassInspection */ + $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 $idx + * + * @return Response + */ + public function show($idx) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Comment::findOrFail($idx)); + } + + /** + * Update the specified resource in storage. + * + * @param int $idx + * + * @return Response + */ + public function update($idx) + { + $this->checkParametersEmpty(); + + $content = $this->getDocument(); + + $attributes = array_get($content, 'data.attributes', []); + $postId = array_get($content, 'data.relationships.post.data.id', null); + if ($postId !== null) { + $attributes['post_id'] = $postId; + } + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = ['post_id' => 'sometimes|required|integer']; + /** @noinspection PhpUndefinedClassInspection */ + $validator = \Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $comment = Comment::findOrFail($idx); + $comment->fill($attributes); + $comment->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $idx + * + * @return Response + */ + public function destroy($idx) + { + $this->checkParametersEmpty(); + + $comment = Comment::findOrFail($idx); + $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..2624b3c2f8d --- /dev/null +++ b/app/Http/Controllers/Demo/PostsController.php @@ -0,0 +1,125 @@ +getResponse(Post::with(['author', 'comments', 'author.posts'])->get()->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.relationships.author.data.id', null); + $attributes['site_id'] = array_get($content, 'data.relationships.site.data.id', null); + + /** @var \Illuminate\Validation\Validator $validator */ + $rules = [ + 'title' => 'required', + 'body' => 'required', + 'author_id' => 'required|integer', + 'site_id' => 'required|integer' + ]; + /** @noinspection PhpUndefinedClassInspection */ + $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 $idx + * @return Response + */ + public function show($idx) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Post::findOrFail($idx)); + } + + /** + * Update the specified resource in storage. + * + * @param int $idx + * @return Response + */ + public function update($idx) + { + $this->checkParametersEmpty(); + + $content = $this->getDocument(); + $attributes = array_get($content, 'data.attributes', []); + + $attributes['author_id'] = array_get($content, 'data.relationships.author.data.id', null); + $attributes['site_id'] = array_get($content, 'data.relationships.site.data.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' + ]; + /** @noinspection PhpUndefinedClassInspection */ + $validator = \Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $post = Post::findOrFail($idx); + $post->fill($attributes); + $post->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $idx + * @return Response + */ + public function destroy($idx) + { + $this->checkParametersEmpty(); + + $comment = Post::findOrFail($idx); + $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..d45ff5a86e4 --- /dev/null +++ b/app/Http/Controllers/Demo/SitesController.php @@ -0,0 +1,107 @@ +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 */ + /** @noinspection PhpUndefinedClassInspection */ + $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 $idx + * + * @return Response + */ + public function show($idx) + { + $this->checkParametersEmpty(); + + return $this->getResponse(Site::findOrFail($idx)); + } + + /** + * Update the specified resource in storage. + * + * @param int $idx + * + * @return Response + */ + public function update($idx) + { + $this->checkParametersEmpty(); + + $attributes = array_get($this->getDocument(), 'data.attributes', []); + + /** @var \Illuminate\Validation\Validator $validator */ + /** @noinspection PhpUndefinedClassInspection */ + $validator = \Validator::make($attributes, ['name' => 'sometimes|required|min:5']); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $site = Site::findOrFail($idx); + $site->fill($attributes); + $site->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $idx + * + * @return Response + */ + public function destroy($idx) + { + $this->checkParametersEmpty(); + + $site = Site::findOrFail($idx); + $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..c0b14f08f14 --- /dev/null +++ b/app/Http/Controllers/Demo/UsersController.php @@ -0,0 +1,152 @@ +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', + ]; + /** @noinspection PhpUndefinedClassInspection */ + $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 $idx + * + * @return Response + */ + public function show($idx) + { + $this->checkParametersEmpty(); + + return $this->getResponse(User::findOrFail($idx)); + } + + /** + * Update the specified resource in storage. + * + * @param int $idx + * + * @return Response + */ + public function update($idx) + { + $this->checkParametersEmpty(); + + if ((int)$idx === self::DEFAULT_USER_ID) { + throw new AccessDeniedHttpException(); + } + + $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', + ]; + /** @noinspection PhpUndefinedClassInspection */ + $validator = \Validator::make($attributes, $rules); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $author = User::findOrFail($idx); + $author->fill($attributes); + $author->save(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Remove the specified resource from storage. + * + * @param int $idx + * + * @return Response + */ + public function destroy($idx) + { + $this->checkParametersEmpty(); + + if ((int)$idx === self::DEFAULT_USER_ID) { + throw new AccessDeniedHttpException(); + } + + $author = User::findOrFail($idx); + $author->delete(); + + return $this->getCodeResponse(Response::HTTP_NO_CONTENT); + } + + /** + * Get JWT for signed in user. + * + * @return string + */ + public function getSignedInUserJwt() + { + $this->checkParametersEmpty(); + + /** @noinspection PhpUndefinedClassInspection */ + $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..525a1a02c83 --- /dev/null +++ b/app/Http/Controllers/JsonApi/JsonApiController.php @@ -0,0 +1,43 @@ +initJsonApiSupport($integration); + } + + /** + * @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) + { + // If you use Eloquent the following helper method will assist you to + // encode database collections and keep your code clean. + + $data = $data instanceof Collection ? $data->all() : $data; + + return $this->getContentResponse($data, $statusCode, $links, $meta); + } +} diff --git a/app/Http/Controllers/JsonApi/LaravelIntegration.php b/app/Http/Controllers/JsonApi/LaravelIntegration.php new file mode 100644 index 00000000000..d910f2af428 --- /dev/null +++ b/app/Http/Controllers/JsonApi/LaravelIntegration.php @@ -0,0 +1,77 @@ +currentRequest === null) { + $this->currentRequest = app(Request::class); + } + + return $this->currentRequest; + } + + /** + * @inheritdoc + * + * @return Response + */ + public function createResponse($content, $statusCode, array $headers) + { + return new Response($content, $statusCode, $headers); + } + + /** + * @inheritdoc + */ + public function getFromContainer($key) + { + return app($key); + } + + /** + * @inheritdoc + */ + public function setInContainer($key, $value) + { + app()->instance($key, $value); + } + + /** + * @inheritdoc + */ + public function hasInContainer($key) + { + return app()->resolved($key); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index ceea60a7a9e..dc61e131e02 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/CorsMiddleware.php b/app/Http/Middleware/CorsMiddleware.php new file mode 100644 index 00000000000..003fbe81c01 --- /dev/null +++ b/app/Http/Middleware/CorsMiddleware.php @@ -0,0 +1,34 @@ +getRequestType()) { + case AnalysisResultInterface::ERR_NO_HOST_HEADER: + Log::debug('CORS error: no Host header in request'); + break; + case AnalysisResultInterface::ERR_ORIGIN_NOT_ALLOWED: + Log::debug('CORS error: request Origin is not allowed'); + break; + case AnalysisResultInterface::ERR_METHOD_NOT_SUPPORTED: + Log::debug('CORS error: request method is not allowed'); + break; + case AnalysisResultInterface::ERR_HEADERS_NOT_SUPPORTED: + Log::debug('CORS error: one or more request headers are not allowed'); + break; + } + + return parent::getResponseOnError($analysisResult); + } +} 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..978cf71eb26 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,20 @@ 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\Http\AppServiceProviderTrait; +use \Neomerx\Limoncello\Contracts\IntegrationInterface; +/** + * @package Neomerx\LimoncelloCollins + */ class AppServiceProvider extends ServiceProvider { + use AppServiceProviderTrait; + /** * Bootstrap any application services. * @@ -23,6 +33,17 @@ public function boot() */ public function register() { - // + $integration = new LaravelIntegration(); + + $this->registerResponses($integration); + $this->registerCodecMatcher($integration); + $this->registerExceptionThrower($integration); + + $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..672636fcaa2 --- /dev/null +++ b/app/Schemas/AuthorSchema.php @@ -0,0 +1,53 @@ +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, array $includeRelationships = []) + { + /** @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..c6109210a14 --- /dev/null +++ b/app/Schemas/CommentSchema.php @@ -0,0 +1,73 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($comment) + { + /** @var Comment $comment */ + return [ + 'body' => $comment->body, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($comment, array $includeRelationships = []) + { + /** @var Comment $comment */ + + // that's an example how $includeRelationships could be used for reducing requests to database + if (isset($includeRelationships['post']) === true) { + // as post will be included as full resource we have to give full resource + $post = $comment->post; + } else { + // as post will be included as just id and type so it's not necessary to load it from database + $post = new Post(); + $post->setAttribute($post->getKeyName(), $comment->post_id); + } + + return [ + 'post' => [self::DATA => $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..81a01d58bea --- /dev/null +++ b/app/Schemas/PostSchema.php @@ -0,0 +1,65 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($post) + { + /** @var Post $post */ + return [ + 'title' => $post->title, + 'body' => $post->body, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($post, array $includeRelationships = []) + { + /** @var Post $post */ + + // that's an example how $includeRelationships could be used for reducing requests to database + if (isset($includeRelationships['author']) === true) { + // as author will be included as full resource we have to give full resource + $author = $post->author; + } else { + // as author will be included as just id and type so it's not necessary to load it from database + $author = new Author(); + $author->setAttribute($author->getKeyName(), $post->author_id); + } + + return [ + 'author' => [self::DATA => $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..fa6a6c80d8b --- /dev/null +++ b/app/Schemas/SiteSchema.php @@ -0,0 +1,63 @@ +id; + } + + /** + * @inheritdoc + */ + public function getAttributes($site) + { + /** @var Site $site */ + return [ + 'name' => $site->name, + ]; + } + + /** + * @inheritdoc + */ + public function getRelationships($site, array $includeRelationships = []) + { + /** @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..94e4a491f93 --- /dev/null +++ b/app/Schemas/UserSchema.php @@ -0,0 +1,41 @@ +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 9f1e7481a3b..7453fda3ea4 100644 --- a/app/User.php +++ b/app/User.php @@ -2,6 +2,8 @@ namespace App; +use \Hash; +use \Carbon\Carbon; use Illuminate\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Auth\Passwords\CanResetPassword; @@ -10,6 +12,17 @@ use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; 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, AuthorizableContract, CanResetPasswordContract @@ -36,4 +49,14 @@ class User extends Model implements AuthenticatableContract, * @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..34e1926959a 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.5.0", + "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 7dcfe2d0736..7df2f5b4239 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..2a0099e42fb --- /dev/null +++ b/config/cors-illuminate.php @@ -0,0 +1,98 @@ + [ + '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:4200' => true, + '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' => true, + 'content-type' => true, + 'authorization' => true, + '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' => true, + 'x-custom-response-header' => null, + ], + + /** + * If access with credentials is supported by the resource. + */ + Settings::KEY_IS_USING_CREDENTIALS => true, + + /** + * 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..965acd7dc5e 100644 --- a/readme.md +++ b/readme.md @@ -1,27 +1,181 @@ -## 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 for Laravel/Lumen](https://github.com/neomerx/cors-illuminate) -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. -## Official Documentation +You might be interested in a single-page JavaScript Application [Limoncello Ember](https://github.com/neomerx/limoncello-ember) that works with this API Sever. + +### In a nutshell -Documentation for the framework can be found on the [Laravel website](http://laravel.com/docs). +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 -## Contributing +```php +class AuthorsController extends Controller +{ + public function show($id) + { + return $this->getResponse(Author::findOrFail($id)); + } +} +``` -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](http://laravel.com/docs/contributions). +It has support for -## Security Vulnerabilities +* Request validation +* Encoding result to JSON API format +* Filling in all required Response headers +* Error handling +* Basic Authentication -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. +This service can be called with ```curl``` and it will return response in JSON API format with correct headers + +``` +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 database/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..bde2f65da61 --- /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..4a9c70c5678 --- /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..a802a68aa73 --- /dev/null +++ b/tests/Demo/Errors/ExceptionHanlderTest.php @@ -0,0 +1,93 @@ +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 + + // The line below will trigger codec matcher init + $this->callGet(self::API_URL_PREFIX . 'sites'); + + /** @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); + } + } +}