diff --git a/.env.testing b/.env.testing index 6c2bdbe6..e7d3eadd 100644 --- a/.env.testing +++ b/.env.testing @@ -10,3 +10,5 @@ DB_DATABASE=":memory:" SCOUT_DRIVER=meilisearch MEILISEARCH_HOST=http://localhost:7700 MEILISEARCH_KEY=masterKey + +SPEC_PATH=./docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d970be..32d61e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v0.5.3 +- Update OpenApi spec and endpoints +- Delete responses now return 204 +- Add contract testing +- Update related ingredients + # v0.5.2 - Add response caching, disabled by default - Cache docker image steps in GH actions diff --git a/app/Console/Commands/OpenBar.php b/app/Console/Commands/OpenBar.php index 90d52a04..88839e94 100644 --- a/app/Console/Commands/OpenBar.php +++ b/app/Console/Commands/OpenBar.php @@ -142,11 +142,11 @@ public function handle() Ingredient::create(['name' => 'Vanilla Extract', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Solution made by macerating and percolating vanilla pods in a solution of ethanol and water.', 'user_id' => 1]); // Bitters - Ingredient::create(['name' => 'Orange bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 28.0, 'description' => 'Orange bitters is a form of bitters, a cocktail flavoring made from such ingredients as the peels of Seville oranges, cardamom, caraway seed, coriander, anise, and burnt sugar in an alcohol base.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Angostura aromatic bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 44.7, 'description' => 'Angostura bitters is a concentrated bitters (herbal alcoholic preparation) based on gentian, herbs, and spices, by House of Angostura in Trinidad and Tobago.', 'origin' => 'Trinidad & Tobago', 'user_id' => 1]); - Ingredient::create(['name' => 'Peach bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 35.0, 'description' => 'Peach bitters flavored with peaches and herbs.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Angostura cocoa bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 38.0, 'description' => 'Top notes of rich bitter, floral, nutty cocoa with a bold infusion of aromatic botanicals provide endless possibilities to remix classic cocktails.', 'origin' => 'Trinidad & Tobago', 'user_id' => 1]); - Ingredient::create(['name' => 'Peychauds Bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 35.0, 'description' => 'It is a gentian-based bitters, comparable to Angostura bitters, but with a predominant anise aroma combined with a background of mint.', 'origin' => 'North America', 'user_id' => 1]); + Ingredient::create(['name' => 'Orange bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 28.0, 'color' => '#ed8300', 'description' => 'Orange bitters is a form of bitters, a cocktail flavoring made from such ingredients as the peels of Seville oranges, cardamom, caraway seed, coriander, anise, and burnt sugar in an alcohol base.', 'origin' => 'Worldwide', 'user_id' => 1]); + Ingredient::create(['name' => 'Angostura aromatic bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 44.7, 'color' => '#e95310', 'description' => 'Angostura bitters is a concentrated bitters (herbal alcoholic preparation) based on gentian, herbs, and spices, by House of Angostura in Trinidad and Tobago.', 'origin' => 'Trinidad & Tobago', 'user_id' => 1]); + Ingredient::create(['name' => 'Peach bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 35.0, 'color' => '#ca7c00', 'description' => 'Peach bitters flavored with peaches and herbs.', 'origin' => 'Worldwide', 'user_id' => 1]); + Ingredient::create(['name' => 'Angostura cocoa bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 38.0, 'color' => '#894c36', 'description' => 'Top notes of rich bitter, floral, nutty cocoa with a bold infusion of aromatic botanicals provide endless possibilities to remix classic cocktails.', 'origin' => 'Trinidad & Tobago', 'user_id' => 1]); + Ingredient::create(['name' => 'Peychauds Bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 35.0, 'color' => '#622426', 'description' => 'It is a gentian-based bitters, comparable to Angostura bitters, but with a predominant anise aroma combined with a background of mint.', 'origin' => 'North America', 'user_id' => 1]); // Liqueurs Ingredient::create(['name' => 'Campari', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'Italian alcoholic liqueur obtained from the infusion of herbs and fruit.', 'color' => '#ca101e', 'origin' => 'Italy', 'user_id' => 1]); @@ -159,10 +159,8 @@ public function handle() Ingredient::create(['name' => 'Crème de cassis (blackcurrant liqueur)', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'It is made from blackcurrants that are crushed and soaked in alcohol, with sugar subsequently added.', 'color' => '#282722', 'origin' => 'France', 'user_id' => 1]); Ingredient::create(['name' => 'Crème de Violette', 'ingredient_category_id' => $liqueurs->id, 'strength' => 16.0, 'description' => 'Crème de violette is a delicate, barely-sweet liqueur made from violet flower petals.', 'color' => '#a5a2fd', 'origin' => 'Worldwide', 'user_id' => 1]); Ingredient::create(['name' => 'Crème de mûre (blackberry liqueur)', 'ingredient_category_id' => $liqueurs->id, 'strength' => 42.3, 'description' => 'Crème de mûre is a liqueur made with fresh blackberries.', 'color' => '#5f1933', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Cointreau', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Orange-flavoured triple sec liqueur.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); Ingredient::create(['name' => 'Grand Marnier', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Orange-flavored liqueur made from a blend of Cognac brandy, distilled essence of bitter orange, and sugar.', 'color' => '#f34e02', 'origin' => 'France', 'user_id' => 1]); Ingredient::create(['name' => 'Suze', 'ingredient_category_id' => $liqueurs->id, 'strength' => 15.0, 'description' => 'Bitter flavored drink made with the roots of the plant gentian.', 'color' => '#ffffff', 'origin' => 'Switzerland', 'user_id' => 1]); - Ingredient::create(['name' => 'Triple Sec', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Triple sec is usually made from orange peels steeped in a spirit derived from sugar beet due to its neutral flavor. Oranges are then harvested when their skin is still green and they have not fully ripened, so the essential oils remain in the skin and not the flesh of the fruit. This spirit is then redistilled and mixed with more neutral spirit, water, and powdered beet sugar resulting in the final liqueur. This process creates a spirit that has a very strong and distinct orange flavor.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); Ingredient::create(['name' => 'Maraschino', 'ingredient_category_id' => $liqueurs->id, 'strength' => 32.0, 'description' => 'Liqueur obtained from the distillation of Marasca cherries. The small, slightly sour fruit of the Tapiwa cherry tree, which grows wild along parts of the Dalmatian coast in Croatia, lends the liqueur its unique aroma.', 'color' => '#ffffff', 'origin' => 'Croatia', 'user_id' => 1]); Ingredient::create(['name' => 'Galliano', 'ingredient_category_id' => $liqueurs->id, 'strength' => 42.3, 'description' => 'Galliano is sweet with vanilla-anise flavour and subtle citrus and woodsy herbal undernotes.', 'color' => '#caa701', 'origin' => 'Italy', 'user_id' => 1]); Ingredient::create(['name' => 'Chambord', 'ingredient_category_id' => $liqueurs->id, 'strength' => 16.5, 'description' => 'Raspberry liqueur modelled after a liqueur produced in the Loire Valley of France during the late 17th century.', 'color' => '#6f1123', 'origin' => 'Worldwide', 'user_id' => 1]); @@ -180,9 +178,11 @@ public function handle() Ingredient::create(['name' => 'Baileys Irish Cream', 'ingredient_category_id' => $liqueurs->id, 'strength' => 17.0, 'description' => 'Baileys Irish Cream is an Irish cream liqueur, an alcoholic drink flavoured with cream, cocoa and Irish whiskey. It is made by Diageo at Nangor Road, in Dublin, Ireland and in Mallusk, Northern Ireland. It is the original Irish cream, invented by a team headed by Tom Jago in 1971 for Gilbeys of Ireland.', 'origin' => 'Ireland', 'user_id' => 1]); Ingredient::create(['name' => 'St-Germain', 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'St-Germain is an elderflower liqueur It is made using the petals of Sambucus nigra from the Savoie region in France, and each bottle is numbered with the year the petals were collected. Petals are collected annually in the spring over a period of three to four weeks, and are often transported by bicycle to collection points to avoid damaging the petals and impacting the flavour.', 'color' => '#f8e888', 'origin' => 'France', 'user_id' => 1]); - $curacao = Ingredient::create(['name' => 'Orange Curaçao', 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Liqueur flavored with the dried peel of the bitter orange laraha, a citrus fruit grown on the Dutch island of Curaçao. Curaçao is used by liqueur makers the world over as a generic name for orange-flavoured liqueurs.', 'color' => '#edaa53', 'origin' => 'Netherlands', 'user_id' => 1]); - Ingredient::create(['name' => 'Dry Curaçao', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Cognac Ferrand\'s innovative owner Alexandre Gabriel, followed an old recipe and modified it to create this curaçao. While Curaçao and sweet oranges are the main ingredients, vanilla, prunes and lemon peel are amongst the other botanicals called for in the old recipe.', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Blue Curaçao', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Liqueur flavored with the dried peel of the bitter orange laraha, a citrus fruit grown on the Dutch island of Curaçao.', 'color' => '#0192fe', 'origin' => 'Netherlands', 'user_id' => 1]); + $curacao = Ingredient::create(['name' => 'Orange Curaçao', 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Liqueur flavored with the dried peel of the bitter orange laraha, a citrus fruit grown on the Dutch island of Curaçao. Curaçao is used by liqueur makers overt the world as a generic name for orange-flavoured liqueurs.', 'color' => '#edaa53', 'origin' => 'Netherlands', 'user_id' => 1]); + Ingredient::create(['name' => 'Dry Curaçao', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'color' => '#ffc613', 'description' => 'While Curaçao and sweet oranges are the main ingredients, vanilla, prunes and lemon peel are amongst the other botanicals called for in the old recipe.', 'origin' => 'Italy', 'user_id' => 1]); + Ingredient::create(['name' => 'Blue Curaçao', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Curaçao with added blue dye.', 'color' => '#0192fe', 'origin' => 'Netherlands', 'user_id' => 1]); + Ingredient::create(['name' => 'Cointreau', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Orange-flavoured triple sec liqueur, it was originally called Curaçao Blanco Triple Sec. Usually more dry tasting than Orange Curaçao.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); + Ingredient::create(['name' => 'Triple Sec', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Triple sec is usually made from orange peels steeped in a spirit derived from sugar beet due to its neutral flavor.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); // Juices $lemonJuice = Ingredient::create(['name' => 'Lemon juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Freshly squeezed lemon juice.', 'color' => '#f3efda', 'user_id' => 1]); @@ -238,7 +238,7 @@ public function handle() Ingredient::create(['name' => 'Dark Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Rum made from caramelized sugar or molasses.', 'origin' => 'Caribbean', 'user_id' => 1]); Ingredient::create(['name' => 'Jamaican Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Rum made in Jamaica.', 'origin' => 'Jamaica', 'user_id' => 1]); Ingredient::create(['name' => 'Rhum agricole', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 50.0, 'color' => '#ffffff', 'description' => 'Rum distilled from freshly squeezed sugarcane juice rather than molasses.', 'origin' => 'Caribbean', 'user_id' => 1]); - Ingredient::create(['name' => 'Overproof Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 50.0, 'color' => '#ffffff', 'description' => 'Rum much higher than the standard 40% ABV (80 proof), with many as high as 75% (150 proof) to 80% (160 proof) available.', 'origin' => 'Caribbean', 'user_id' => 1]); + Ingredient::create(['name' => 'Overproof Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 50.0, 'color' => '#5d201a', 'description' => 'Rum much higher than the standard 40% ABV (80 proof), with many as high as 75% (150 proof) to 80% (160 proof) available.', 'origin' => 'Caribbean', 'user_id' => 1]); Ingredient::create(['name' => 'Cachaça', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Distilled spirit made from fermented sugarcane juice.', 'origin' => 'Brazil', 'user_id' => 1]); Ingredient::create(['name' => 'Pisco', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Made by distilling fermented grape juice into a high-proof spirit.', 'origin' => 'South America', 'user_id' => 1]); diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 076d593c..aaa80b72 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -50,8 +50,8 @@ public function register(RegisterRequest $req) $user->search_api_key = SearchActions::getPublicApiKey(); $user->save(); - return new UserResource( + return (new UserResource( $user->load('favorites', 'shelfIngredients', 'shoppingLists') - ); + ))->response()->setStatusCode(200); } } diff --git a/app/Http/Controllers/CocktailController.php b/app/Http/Controllers/CocktailController.php index e35a0a94..ea350193 100644 --- a/app/Http/Controllers/CocktailController.php +++ b/app/Http/Controllers/CocktailController.php @@ -96,7 +96,7 @@ public function store(CocktailService $cocktailService, CocktailRequest $request /** * Update a single cocktail by id */ - public function update(CocktailService $cocktailService, CocktailRequest $request, int $id): JsonResponse + public function update(CocktailService $cocktailService, CocktailRequest $request, int $id) { try { $cocktail = $cocktailService->updateCocktail( @@ -118,10 +118,7 @@ public function update(CocktailService $cocktailService, CocktailRequest $reques $cocktail->load('ingredients.ingredient', 'images', 'tags', 'glass', 'ingredients.substitutes'); - return (new CocktailResource($cocktail)) - ->response() - ->setStatusCode(201) - ->header('Location', route('cocktails.show', $cocktail->id)); + return new CocktailResource($cocktail); } /** @@ -131,7 +128,7 @@ public function delete(int $id) { Cocktail::findOrFail($id)->delete(); - return new SuccessActionResource((object) ['id' => $id]); + return response(null, 204); } /** diff --git a/app/Http/Controllers/GlassController.php b/app/Http/Controllers/GlassController.php index 70c70720..dc764fb6 100644 --- a/app/Http/Controllers/GlassController.php +++ b/app/Http/Controllers/GlassController.php @@ -3,23 +3,56 @@ namespace Kami\Cocktail\Http\Controllers; -use Illuminate\Http\Request; +use Illuminate\Http\Response; use Kami\Cocktail\Models\Glass; +use Illuminate\Http\JsonResponse; +use Kami\Cocktail\Http\Requests\GlassRequest; use Kami\Cocktail\Http\Resources\GlassResource; +use Illuminate\Http\Resources\Json\JsonResource; class GlassController extends Controller { - public function index() + public function index(): JsonResource { $glasses = Glass::orderBy('name')->get(); return GlassResource::collection($glasses); } - public function show(int $id) + public function show(int $id): JsonResource { $glass = Glass::findOrFail($id); return new GlassResource($glass); } + + public function store(GlassRequest $request): JsonResponse + { + $glass = new Glass(); + $glass->name = $request->post('name'); + $glass->description = $request->post('description'); + $glass->save(); + + return (new GlassResource($glass)) + ->response() + ->setStatusCode(201) + ->header('Location', route('glasses.show', $glass->id)); + } + + public function update(int $id, GlassRequest $request): JsonResource + { + $glass = Glass::findOrFail($id); + $glass->name = $request->post('name'); + $glass->description = $request->post('description'); + $glass->save(); + + return new GlassResource($glass); + } + + public function delete(int $id): Response + { + Glass::findOrFail($id)->delete(); + + return response(null, 204); + } } diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index d0e66bca..5a33a487 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -37,6 +37,6 @@ public function delete(int $id) { Image::findOrFail($id)->delete(); - return new SuccessActionResource((object) ['id' => $id]); + return response(null, 204); } } diff --git a/app/Http/Controllers/IngredientCategoryController.php b/app/Http/Controllers/IngredientCategoryController.php index d6297462..192f4054 100644 --- a/app/Http/Controllers/IngredientCategoryController.php +++ b/app/Http/Controllers/IngredientCategoryController.php @@ -33,7 +33,8 @@ public function store(IngredientCategoryRequest $request) return (new IngredientCategoryResource($category)) ->response() - ->setStatusCode(201); + ->setStatusCode(201) + ->header('Location', route('ingredient-categories.show', $category->id)); } public function update(IngredientCategoryRequest $request, int $id) @@ -43,15 +44,13 @@ public function update(IngredientCategoryRequest $request, int $id) $category->description = $request->post('description'); $category->save(); - return (new IngredientCategoryResource($category)) - ->response() - ->setStatusCode(201); + return new IngredientCategoryResource($category); } public function delete(int $id) { IngredientCategory::findOrFail($id)->delete(); - - return new SuccessActionResource((object) ['id' => $id]); + + return response(null, 204); } } diff --git a/app/Http/Controllers/IngredientController.php b/app/Http/Controllers/IngredientController.php index 3bdf33d6..ef3d71a2 100644 --- a/app/Http/Controllers/IngredientController.php +++ b/app/Http/Controllers/IngredientController.php @@ -55,7 +55,10 @@ public function store(IngredientService $ingredientService, IngredientRequest $r $request->post('images', []) ); - return new IngredientResource($ingredient); + return (new IngredientResource($ingredient)) + ->response() + ->setStatusCode(201) + ->header('Location', route('ingredients.show', $ingredient->id)); } public function update(IngredientService $ingredientService, IngredientRequest $request, int $id) @@ -80,6 +83,6 @@ public function delete(int $id) { Ingredient::findOrFail($id)->delete(); - return new SuccessActionResource((object) ['id' => $id]); + return response(null, 204); } } diff --git a/app/Http/Controllers/ShelfController.php b/app/Http/Controllers/ShelfController.php index 3b386631..b2392034 100644 --- a/app/Http/Controllers/ShelfController.php +++ b/app/Http/Controllers/ShelfController.php @@ -7,8 +7,6 @@ use Illuminate\Http\Request; use Kami\Cocktail\Models\UserIngredient; use Kami\Cocktail\Models\UserShoppingList; -use Kami\Cocktail\Http\Resources\ErrorResource; -use Kami\Cocktail\Http\Resources\SuccessActionResource; use Kami\Cocktail\Http\Resources\UserIngredientResource; use Kami\Cocktail\Http\Requests\UserIngredientBatchRequest; @@ -64,9 +62,9 @@ public function delete(Request $request, int $ingredientId) ->where('ingredient_id', $ingredientId) ->delete(); } catch (Throwable $e) { - return new ErrorResource($e); + abort(500, $e->getMessage()); } - return new SuccessActionResource((object) ['id' => $ingredientId]); + return response(null, 204); } } diff --git a/app/Http/Controllers/ShoppingListController.php b/app/Http/Controllers/ShoppingListController.php index 0722abb1..a16fb849 100644 --- a/app/Http/Controllers/ShoppingListController.php +++ b/app/Http/Controllers/ShoppingListController.php @@ -6,12 +6,20 @@ use Throwable; use Illuminate\Http\Request; use Kami\Cocktail\Models\UserShoppingList; +use Illuminate\Http\Resources\Json\JsonResource; use Kami\Cocktail\Http\Resources\SuccessActionResource; use Kami\Cocktail\Http\Resources\UserShoppingListResource; class ShoppingListController extends Controller { - public function batchStore(Request $request) + public function index(Request $request): JsonResource + { + return UserShoppingListResource::collection( + $request->user()->shoppingLists->load('ingredient') + ); + } + + public function batchStore(Request $request): JsonResource { $ingredientIds = $request->post('ingredient_ids'); @@ -28,7 +36,7 @@ public function batchStore(Request $request) return UserShoppingListResource::collection($models); } - public function batchDelete(Request $request) + public function batchDelete(Request $request): JsonResource { $ingredientIds = $request->post('ingredient_ids'); diff --git a/app/Http/Requests/GlassRequest.php b/app/Http/Requests/GlassRequest.php new file mode 100644 index 00000000..c46956ac --- /dev/null +++ b/app/Http/Requests/GlassRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules() + { + return [ + 'name' => 'required', + ]; + } +} diff --git a/app/Http/Resources/UserShoppingListResource.php b/app/Http/Resources/UserShoppingListResource.php index d8fff330..d825f82c 100644 --- a/app/Http/Resources/UserShoppingListResource.php +++ b/app/Http/Resources/UserShoppingListResource.php @@ -21,7 +21,11 @@ public function toArray($request) return [ 'id' => $this->id, 'user_id' => $this->user_id, - 'ingredient_id' => $this->ingredient_id + 'ingredient' => [ + 'id' => $this->ingredient_id, + 'slug' => $this->ingredient->slug, + 'name' => $this->ingredient->name, + ] ]; } } diff --git a/app/Models/Cocktail.php b/app/Models/Cocktail.php index 86432275..df38170a 100644 --- a/app/Models/Cocktail.php +++ b/app/Models/Cocktail.php @@ -19,7 +19,7 @@ class Cocktail extends Model implements SiteSearchable private $appImagesDir = 'cocktails/'; - protected static function booted() + protected static function booted(): void { static::saved(function($cocktail) { SearchActions::updateSearchIndex($cocktail); @@ -57,7 +57,7 @@ public function tags(): BelongsToMany return $this->belongsToMany(Tag::class); } - public function delete() + public function delete(): ?bool { $this->deleteImages(); @@ -90,7 +90,8 @@ public function toSearchableArray(): array 'short_ingredients' => $this->ingredients->pluck('ingredient.name'), 'user_id' => $this->user_id, 'tags' => $this->tags->pluck('name'), - 'date' => $this->updated_at->format('Y-m-d H:i:s') + 'date' => $this->updated_at->format('Y-m-d H:i:s'), + 'glass' => $this->glass->name ?? null ]; } } diff --git a/app/Models/CocktailFavorite.php b/app/Models/CocktailFavorite.php index 6cd85410..21ba99ac 100644 --- a/app/Models/CocktailFavorite.php +++ b/app/Models/CocktailFavorite.php @@ -3,14 +3,15 @@ namespace Kami\Cocktail\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Factories\HasFactory; class CocktailFavorite extends Model { use HasFactory; - public function cocktail() + public function cocktail(): BelongsTo { return $this->belongsTo(Cocktail::class); } diff --git a/app/Models/Glass.php b/app/Models/Glass.php index 4fe4dbb9..f336e086 100644 --- a/app/Models/Glass.php +++ b/app/Models/Glass.php @@ -1,4 +1,5 @@ delete($this->file_path); } - parent::delete(); + return parent::delete(); } public function getImageUrl(): ?string diff --git a/app/Models/Ingredient.php b/app/Models/Ingredient.php index 51f8283a..44de57f4 100644 --- a/app/Models/Ingredient.php +++ b/app/Models/Ingredient.php @@ -7,6 +7,7 @@ use Spatie\Sluggable\HasSlug; use Kami\Cocktail\SearchActions; use Spatie\Sluggable\SlugOptions; +use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -30,7 +31,7 @@ class Ingredient extends Model implements SiteSearchable 'parent_ingredient_id', ]; - protected static function booted() + protected static function booted(): void { static::saved(function($ing) { SearchActions::updateSearchIndex($ing); @@ -73,7 +74,7 @@ public function parentIngredient(): BelongsTo return $this->belongsTo(Ingredient::class, 'parent_ingredient_id', 'id'); } - public function cocktailsAsSubstituteIngredient() + public function cocktailsAsSubstituteIngredient(): Collection { return $this->cocktailIngredientSubstitutes->pluck('cocktailIngredient.cocktail'); } @@ -83,7 +84,7 @@ public function cocktailIngredientSubstitutes(): HasMany return $this->hasMany(CocktailIngredientSubstitute::class); } - public function getAllRelatedIngredients() + public function getAllRelatedIngredients(): Collection { // This creates "Related" group of the ingredients "on-the-fly" if ($this->parent_ingredient_id !== null) { @@ -97,7 +98,7 @@ public function getAllRelatedIngredients() return $this->varieties; } - public function delete() + public function delete(): ?bool { $this->deleteImages(); diff --git a/app/Models/IngredientCategory.php b/app/Models/IngredientCategory.php index 62fd61bb..b46ec8ae 100644 --- a/app/Models/IngredientCategory.php +++ b/app/Models/IngredientCategory.php @@ -1,4 +1,5 @@ belongsTo(Ingredient::class); + } } diff --git a/app/SearchActions.php b/app/SearchActions.php index 16063f2f..3a3e4562 100644 --- a/app/SearchActions.php +++ b/app/SearchActions.php @@ -55,7 +55,7 @@ public static function updateIndexSettings(): void $engine = app(\Laravel\Scout\EngineManager::class)->engine(); $engine->index('cocktails')->updateSettings([ - 'filterableAttributes' => ['tags', 'user_id'], + 'filterableAttributes' => ['tags', 'user_id', 'glass'], 'sortableAttributes' => ['name', 'date'], 'searchableAttributes' => [ 'name', diff --git a/app/Services/CocktailService.php b/app/Services/CocktailService.php index 71d5e4cd..74b16a6c 100644 --- a/app/Services/CocktailService.php +++ b/app/Services/CocktailService.php @@ -8,7 +8,7 @@ use Illuminate\Log\LogManager; use Kami\Cocktail\Models\User; use Kami\Cocktail\Models\Image; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Collection; use Kami\Cocktail\Models\Cocktail; use Illuminate\Database\DatabaseManager; use Kami\Cocktail\Models\CocktailFavorite; @@ -239,7 +239,7 @@ public function updateCocktail( * @param int $userId * @return \Illuminate\Database\Eloquent\Collection<\Kami\Cocktail\Models\Cocktail> */ - public function getCocktailsByUserIngredients(int $userId) + public function getCocktailsByUserIngredients(int $userId): Collection { // https://stackoverflow.com/questions/19930070/mysql-query-to-select-all-except-something $cocktailIds = $this->db->table('cocktails AS c') diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index 84aedf8a..3ef067fe 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -13,7 +13,7 @@ class ImageService /** * Uploads and saves an image with filepath * - * @param array $requestImages + * @param array $requestImages * @return array<\Kami\Cocktail\Models\Image> * @throws ImageUploadException */ diff --git a/composer.json b/composer.json index f5b636b9..05858ef5 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "require-dev": { "barryvdh/laravel-debugbar": "^3.7", "fakerphp/faker": "^1.9.1", + "hotmeteor/spectator": "^1.7", "laravel/pint": "^1.0", "laravel/sail": "^1.0.1", "mockery/mockery": "^1.4.4", diff --git a/composer.lock b/composer.lock index 2088f0eb..99169799 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "947e57c73915f7ab7db00e1a74cdc1cd", + "content-hash": "08d16fa457c0cd7a3b3b0d5cfcd34574", "packages": [ { "name": "brick/math", @@ -6527,6 +6527,75 @@ ], "time": "2022-07-11T09:26:42+00:00" }, + { + "name": "cebe/php-openapi", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/cebe/php-openapi.git", + "reference": "020d72b8e3a9a60bc229953e93eda25c49f46f45" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cebe/php-openapi/zipball/020d72b8e3a9a60bc229953e93eda25c49f46f45", + "reference": "020d72b8e3a9a60bc229953e93eda25c49f46f45", + "shasum": "" + }, + "require": { + "ext-json": "*", + "justinrainbow/json-schema": "^5.2", + "php": ">=7.1.0", + "symfony/yaml": "^3.4 || ^4 || ^5 || ^6" + }, + "conflict": { + "symfony/yaml": "3.4.0 - 3.4.4 || 4.0.0 - 4.4.17 || 5.0.0 - 5.1.9 || 5.2.0" + }, + "require-dev": { + "apis-guru/openapi-directory": "1.0.0", + "cebe/indent": "*", + "mermade/openapi3-examples": "1.0.0", + "nexmo/api-specification": "1.0.0", + "oai/openapi-specification": "3.0.3", + "phpstan/phpstan": "^0.12.0", + "phpunit/phpunit": "^6.5 || ^7.5 || ^8.5 || ^9.4" + }, + "bin": [ + "bin/php-openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "cebe\\openapi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc", + "homepage": "https://cebe.cc/", + "role": "Creator" + } + ], + "description": "Read and write OpenAPI yaml/json files and make the content accessable in PHP objects.", + "homepage": "https://github.com/cebe/php-openapi#readme", + "keywords": [ + "openapi" + ], + "support": { + "issues": "https://github.com/cebe/php-openapi/issues", + "source": "https://github.com/cebe/php-openapi" + }, + "time": "2022-04-20T14:46:44+00:00" + }, { "name": "composer/class-map-generator", "version": "1.0.0", @@ -6930,6 +6999,138 @@ }, "time": "2020-07-09T08:09:16+00:00" }, + { + "name": "hotmeteor/spectator", + "version": "v1.7.1", + "source": { + "type": "git", + "url": "https://github.com/hotmeteor/spectator.git", + "reference": "6fdc6df6c7cd0d0ab6e695b44e2bd02f250d4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hotmeteor/spectator/zipball/6fdc6df6c7cd0d0ab6e695b44e2bd02f250d4002", + "reference": "6fdc6df6c7cd0d0ab6e695b44e2bd02f250d4002", + "shasum": "" + }, + "require": { + "cebe/php-openapi": "^1.5", + "ext-json": "*", + "laravel/framework": "^8.40.0|^9.0.0", + "opis/json-schema": "^2.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "nunomaduro/collision": "^5.1|^6.0", + "orchestra/testbench": "^6.0|^7.0", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spectator\\SpectatorServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spectator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adam Campbell", + "email": "adam@hotmeteor.com" + } + ], + "description": "Testing helpers for your OpenAPI spec", + "keywords": [ + "laravel", + "openapi", + "spectator", + "testing" + ], + "support": { + "issues": "https://github.com/hotmeteor/spectator/issues", + "source": "https://github.com/hotmeteor/spectator/tree/v1.7.1" + }, + "time": "2022-10-24T12:18:48+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.12", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/justinrainbow/json-schema/issues", + "source": "https://github.com/justinrainbow/json-schema/tree/5.2.12" + }, + "time": "2022-04-13T08:02:27+00:00" + }, { "name": "laravel/pint", "version": "v1.2.0", @@ -7509,6 +7710,196 @@ ], "time": "2022-08-30T19:02:01+00:00" }, + { + "name": "opis/json-schema", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.0", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.3.0" + }, + "time": "2022-01-08T20:38:03+00:00" + }, + { + "name": "opis/string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.0.1" + }, + "time": "2022-01-14T15:42:23+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.3", diff --git a/config/bar-assistant.php b/config/bar-assistant.php index dfc3f9c7..c2fb52b1 100644 --- a/config/bar-assistant.php +++ b/config/bar-assistant.php @@ -11,7 +11,7 @@ | */ - 'version' => 'v0.5.2', + 'version' => 'v0.5.3', /* |-------------------------------------------------------------------------- diff --git a/config/spectator.php b/config/spectator.php new file mode 100644 index 00000000..3e6465c2 --- /dev/null +++ b/config/spectator.php @@ -0,0 +1,68 @@ + env('SPEC_SOURCE', 'local'), + + /* + |-------------------------------------------------------------------------- + | Sources + |-------------------------------------------------------------------------- + | + | Here you may configure as many sources as you wish, and you + | may even configure multiple source of the same type. Defaults have + | been setup for each driver as an example of the required options. + | + */ + + 'sources' => [ + 'local' => [ + 'source' => 'local', + 'base_path' => env('SPEC_PATH'), + ], + + 'remote' => [ + 'source' => 'remote', + 'base_path' => env('SPEC_PATH'), + 'params' => env('SPEC_URL_PARAMS', ''), + ], + + 'github' => [ + 'source' => 'github', + 'base_path' => env('SPEC_GITHUB_PATH'), + 'repo' => env('SPEC_GITHUB_REPO'), + 'token' => env('SPEC_GITHUB_TOKEN'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Paths + |-------------------------------------------------------------------------- + | + | Configure path defaults, like prefixes. + | + */ + + 'path_prefix' => 'api/', + + /* + |-------------------------------------------------------------------------- + | Errors + |-------------------------------------------------------------------------- + | + | Suppress errors in tests and only show messages. + | + */ + + 'suppress_errors' => false, +]; diff --git a/database/migrations/2022_09_26_171904_create_glasses_table.php b/database/migrations/2022_09_26_171904_create_glasses_table.php index d9aa69e1..48b41a05 100644 --- a/database/migrations/2022_09_26_171904_create_glasses_table.php +++ b/database/migrations/2022_09_26_171904_create_glasses_table.php @@ -17,6 +17,7 @@ public function up() $table->id(); $table->string('name'); $table->text('description')->nullable(); + $table->timestamps(); }); } diff --git a/docs/open-api-spec.yml b/docs/open-api-spec.yml index 6540c847..9d00ec3f 100644 --- a/docs/open-api-spec.yml +++ b/docs/open-api-spec.yml @@ -63,6 +63,9 @@ paths: application/json: schema: type: object + required: + - email + - password properties: email: type: string @@ -106,12 +109,12 @@ paths: - "Cocktails" summary: 'Show a paginated list of cocktails' responses: - '200': + '201': description: Successful response content: application/json: schema: - allOf: + anyOf: - type: object properties: data: @@ -129,17 +132,21 @@ paths: schema: $ref: '#/components/schemas/CocktailRequest' responses: - '200': + '201': description: Successful response + headers: + Location: + description: Absolute URL to new resource + schema: + type: string + example: 'http://localhost/api/cocktails/1' content: application/json: schema: type: object properties: data: - type: array - items: - $ref: '#/components/schemas/Cocktail' + $ref: '#/components/schemas/Cocktail' '422': $ref: '#/components/responses/UnprocessableEntity' /register: @@ -197,6 +204,8 @@ paths: properties: data: $ref: '#/components/schemas/Cocktail' + '404': + $ref: '#/components/responses/NotFound' put: tags: - "Cocktails" @@ -216,6 +225,8 @@ paths: properties: data: $ref: '#/components/schemas/Cocktail' + '404': + $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/UnprocessableEntity' delete: @@ -223,8 +234,10 @@ paths: - "Cocktails" summary: 'Delete a specific cocktail' responses: - '200': - description: Successful response + '204': + description: Successfully deleted cocktail response + '404': + $ref: '#/components/responses/NotFound' /cocktails/{id}/toggle-favorite: parameters: - name: id @@ -240,6 +253,24 @@ paths: responses: '200': description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + id: + type: integer + description: 'Cocktail favorite ID' + example: 1 + is_favorited: + type: boolean + description: 'Is cocktail favorited' + example: true + '404': + $ref: '#/components/responses/NotFound' /cocktails/random: get: tags: @@ -316,8 +347,14 @@ paths: schema: $ref: '#/components/schemas/IngredientRequest' responses: - '200': + '201': description: Successful response + headers: + Location: + description: Absolute URL to new resource + schema: + type: string + example: 'http://localhost/api/ingredients/1' content: application/json: schema: @@ -379,8 +416,8 @@ paths: - Ingredients summary: 'Delete a specific ingredient' responses: - '200': - description: Successful response + '204': + description: Successfully deleted ingredient '404': $ref: '#/components/responses/NotFound' /glasses: @@ -400,6 +437,33 @@ paths: type: array items: $ref: '#/components/schemas/Glass' + post: + tags: + - Glasses + summary: Create a new glass type + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GlassRequest' + responses: + '201': + description: Successful response + headers: + Location: + description: Absolute URL to new resource + schema: + type: string + example: 'http://localhost/api/glasses/1' + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Glass' + '422': + $ref: '#/components/responses/UnprocessableEntity' /glasses/{id}: get: tags: @@ -423,6 +487,38 @@ paths: $ref: '#/components/schemas/Glass' '404': $ref: '#/components/responses/NotFound' + put: + tags: + - Glasses + summary: Update a specific glass type + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GlassRequest' + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Glass' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + delete: + tags: + - Glasses + summary: Delete specific glass type + responses: + '204': + description: Successful response + '404': + $ref: '#/components/responses/NotFound' /images: post: tags: @@ -503,8 +599,10 @@ paths: - Images summary: Remove a specific image responses: - '200': + '204': description: Successful response + '404': + $ref: '#/components/responses/NotFound' /ingredient-categories: get: tags: @@ -532,8 +630,14 @@ paths: schema: $ref: '#/components/schemas/IngredientCategoryRequest' responses: - '200': + '201': description: Successful response + headers: + Location: + description: Absolute URL to new resource + schema: + type: string + example: 'http://localhost/api/ingredient-categories/1' content: application/json: schema: @@ -565,6 +669,8 @@ paths: properties: data: $ref: '#/components/schemas/IngredientCategory' + '404': + $ref: '#/components/responses/NotFound' put: tags: - "Ingredient categories" @@ -584,6 +690,8 @@ paths: properties: data: $ref: '#/components/schemas/IngredientCategory' + '404': + $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/UnprocessableEntity' delete: @@ -591,8 +699,10 @@ paths: - "Ingredient categories" summary: Delete specific ingredient category responses: - '200': + '204': description: Successful response + '404': + $ref: '#/components/responses/NotFound' /shelf: get: tags: @@ -661,6 +771,8 @@ paths: properties: data: $ref: '#/components/schemas/UserIngredient' + '404': + $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/UnprocessableEntity' delete: @@ -668,8 +780,10 @@ paths: - "User shelf" summary: Delete a single ingredient from user shelf responses: - '200': - description: Successful response + '204': + description: Successfully deleted ingredient + '404': + $ref: '#/components/responses/NotFound' /user: get: tags: @@ -685,11 +799,28 @@ paths: properties: data: $ref: '#/components/schemas/User' - /shopping-lists/batch: + /shopping-list: + get: + tags: + - "Shopping list" + summary: "Show ingredients on your shopping list" + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserShoppingList' + /shopping-list/batch-store: post: tags: - "Shopping list" - summary: "Add ingredients to a shopping list" + summary: "Add multiple ingredients to a shopping list" requestBody: content: application/json: @@ -713,12 +844,11 @@ paths: type: array items: $ref: '#/components/schemas/UserShoppingList' - '422': - $ref: '#/components/responses/UnprocessableEntity' - delete: + /shopping-list/batch-delete: + post: tags: - "Shopping list" - summary: "Delete ingredients from a shopping list" + summary: "Delete multiple ingredients from a shopping list" requestBody: content: application/json: @@ -732,7 +862,23 @@ paths: type: integer responses: '200': - description: Successful response + description: Successful response with deleted ingredient ids + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + success: + type: boolean + example: true + ingredient_ids: + type: array + example: [1, 2, 3] + items: + type: integer components: securitySchemes: user_token: @@ -761,6 +907,7 @@ components: schemas: Glass: type: object + nullable: true properties: id: type: integer @@ -775,6 +922,10 @@ components: example: 'Description of glass' IngredientRequest: type: object + required: + - name + - strength + - ingredient_category_id properties: name: type: string @@ -828,9 +979,11 @@ components: example: 40.0 description: type: string + nullable: true example: 'A type of whiskey' origin: type: string + nullable: true example: 'North America' main_image_id: type: integer @@ -898,6 +1051,8 @@ components: example: 'A category of base spirits' IngredientCategoryRequest: type: object + required: + - name properties: name: type: string @@ -906,6 +1061,18 @@ components: type: string nullable: true example: 'A category of base spirits' + GlassRequest: + type: object + required: + - name + properties: + name: + type: string + example: Cocktail glass + description: + type: string + nullable: true + example: 'A stemmed glass' Version: type: object properties: @@ -937,6 +1104,7 @@ components: copyright: type: string example: 'Somewhere from the web' + nullable: true last_modified: type: string example: '2022-11-17T09:52:48.000000Z' @@ -1140,6 +1308,8 @@ components: example: 0 ImageRequest: type: object + required: + - images properties: images: type: array @@ -1151,6 +1321,7 @@ components: format: binary copyright: type: string + nullable: true UserIngredient: type: object properties: @@ -1172,15 +1343,24 @@ components: user_id: type: integer example: 1 - ingredient_id: - type: integer - example: 1 + ingredient: + type: object + properties: + id: + type: integer + example: 1 + slug: + type: string + example: 'ingredient-1' + name: + type: string + example: 'Ingredient 1' ValidationErrorResponse: type: object properties: message: type: string - example: "The cocktail name must be a string. (and 4 more errors)" + example: "The resource name must be a string. (and 4 more errors)" errors: type: object properties: @@ -1197,9 +1377,11 @@ components: properties: first: type: string + nullable: true example: http://localhost/api/resource?page=1 last: type: string + nullable: true example: http://localhost/api/resource?page=10 prev: type: string diff --git a/phpunit.xml b/phpunit.xml index 86fb380d..05378844 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,9 @@ ./tests/Feature + diff --git a/routes/api.php b/routes/api.php index 8b4fbd32..bb0a6470 100644 --- a/routes/api.php +++ b/routes/api.php @@ -49,7 +49,7 @@ Route::prefix('ingredients')->group(function() { Route::get('/', [IngredientController::class, 'index']); Route::post('/', [IngredientController::class, 'store']); - Route::get('/{id}', [IngredientController::class, 'show']); + Route::get('/{id}', [IngredientController::class, 'show'])->name('ingredients.show'); Route::put('/{id}', [IngredientController::class, 'update']); Route::delete('/{id}', [IngredientController::class, 'delete']); }); @@ -57,7 +57,7 @@ Route::prefix('ingredient-categories')->group(function() { Route::get('/', [IngredientCategoryController::class, 'index']); Route::post('/', [IngredientCategoryController::class, 'store']); - Route::get('/{id}', [IngredientCategoryController::class, 'show']); + Route::get('/{id}', [IngredientCategoryController::class, 'show'])->name('ingredient-categories.show'); Route::put('/{id}', [IngredientCategoryController::class, 'update']); Route::delete('/{id}', [IngredientCategoryController::class, 'delete']); }); @@ -81,14 +81,18 @@ Route::delete('/{id}', [ImageController::class, 'delete']); }); - Route::prefix('shopping-lists')->group(function() { - Route::post('/batch', [ShoppingListController::class, 'batchStore']); - Route::delete('/batch', [ShoppingListController::class, 'batchDelete']); + Route::prefix('shopping-list')->group(function() { + Route::get('/', [ShoppingListController::class, 'index']); + Route::post('/batch-store', [ShoppingListController::class, 'batchStore']); + Route::post('/batch-delete', [ShoppingListController::class, 'batchDelete']); }); Route::prefix('glasses')->group(function() { Route::get('/', [GlassController::class, 'index']); - Route::get('/{id}', [GlassController::class, 'show']); + Route::post('/', [GlassController::class, 'store']); + Route::get('/{id}', [GlassController::class, 'show'])->name('glasses.show'); + Route::put('/{id}', [GlassController::class, 'update']); + Route::delete('/{id}', [GlassController::class, 'delete']); }); }); diff --git a/storage/uploads/cocktails/mai-tai.jpg b/storage/uploads/cocktails/mai-tai.jpg index 43ea0712..4a1732ba 100644 Binary files a/storage/uploads/cocktails/mai-tai.jpg and b/storage/uploads/cocktails/mai-tai.jpg differ diff --git a/tests/Feature/AuthControllerTest.php b/tests/Feature/AuthControllerTest.php index ed15d8ff..0dc9f4fa 100644 --- a/tests/Feature/AuthControllerTest.php +++ b/tests/Feature/AuthControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use Tests\TestCase; +use Spectator\Spectator; use Kami\Cocktail\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -11,6 +12,13 @@ class AuthControllerTest extends TestCase { use RefreshDatabase; + public function setUp(): void + { + parent::setUp(); + + Spectator::using('open-api-spec.yml'); + } + public function test_authenticate_response() { $user = User::factory()->create([ @@ -24,6 +32,8 @@ public function test_authenticate_response() ]); $response->assertStatus(200); + $response->assertValidRequest(); + $response->assertValidResponse(); } public function test_logout_response() @@ -36,6 +46,7 @@ public function test_logout_response() $response = $this->postJson('/api/logout'); $response->assertStatus(200); + $response->assertValidResponse(); } public function test_register_response() @@ -47,6 +58,8 @@ public function test_register_response() 'name' => 'Test Guy', ]); - $response->assertCreated(); + $response->assertSuccessful(); + $response->assertValidRequest(); + $response->assertValidResponse(); } } diff --git a/tests/Feature/CocktailControllerTest.php b/tests/Feature/CocktailControllerTest.php index fbbdc809..28b367bb 100644 --- a/tests/Feature/CocktailControllerTest.php +++ b/tests/Feature/CocktailControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use Tests\TestCase; +use Spectator\Spectator; use Kami\Cocktail\Models\User; use Kami\Cocktail\Models\Cocktail; use Kami\Cocktail\Models\Ingredient; @@ -17,6 +18,8 @@ public function setUp(): void { parent::setUp(); + Spectator::using('open-api-spec.yml'); + $this->actingAs( User::factory()->create() ); @@ -37,6 +40,9 @@ public function test_cocktail_show_response() ->where('data.name', 'Test 1') ->etc() ); + + $response->assertValidRequest(); + $response->assertValidResponse(); } public function test_cocktail_show_using_slug_response() @@ -46,6 +52,9 @@ public function test_cocktail_show_using_slug_response() $response = $this->getJson('/api/cocktails/' . $cocktail->slug); $response->assertStatus(200); + + $response->assertValidRequest(); + $response->assertValidResponse(); } public function test_cocktail_create_response() @@ -101,6 +110,9 @@ public function test_cocktail_create_response() }) ->etc() ); + + $response->assertValidRequest(); + $response->assertValidResponse(201); } public function test_cocktail_update_response() @@ -133,8 +145,10 @@ public function test_cocktail_update_response() ] ]); - $response->assertStatus(201); - $this->assertNotNull($response->headers->get('Location', null)); + $response->assertSuccessful(); + + $response->assertValidRequest(); + $response->assertValidResponse(200); } public function test_cocktail_delete_response() @@ -143,7 +157,9 @@ public function test_cocktail_delete_response() $response = $this->deleteJson('/api/cocktails/' . $cocktail->id); - $response->assertStatus(200); + $response->assertNoContent(); + + $response->assertValidResponse(204); } public function test_user_shelf_cocktails_response() @@ -151,6 +167,8 @@ public function test_user_shelf_cocktails_response() $response = $this->getJson('/api/cocktails/user-shelf'); $response->assertStatus(200); + + $response->assertValidResponse(200); } public function test_user_favorites_cocktails_response() @@ -158,5 +176,7 @@ public function test_user_favorites_cocktails_response() $response = $this->getJson('/api/cocktails/user-favorites'); $response->assertStatus(200); + + $response->assertValidResponse(200); } } diff --git a/tests/Feature/HealthControllerTest.php b/tests/Feature/HealthControllerTest.php deleted file mode 100644 index 3aa7846a..00000000 --- a/tests/Feature/HealthControllerTest.php +++ /dev/null @@ -1,19 +0,0 @@ -getJson('/api/server/version'); - - $response->assertStatus(200); - - $this->assertSame('Bar Assistant', $response['data']['name']); - $this->assertNotNull($response['data']['version']); - } -} diff --git a/tests/Feature/IngredientCategoryControllerTest.php b/tests/Feature/IngredientCategoryControllerTest.php new file mode 100644 index 00000000..8ab32e39 --- /dev/null +++ b/tests/Feature/IngredientCategoryControllerTest.php @@ -0,0 +1,129 @@ +actingAs( + User::factory()->create() + ); + } + + public function test_list_categories_response() + { + IngredientCategory::factory()->count(10)->create(); + + $response = $this->getJson('/api/ingredient-categories'); + + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('data', 10) + ->etc() + ); + + $response->assertValidResponse(); + } + + public function test_show_category_response() + { + $cat = IngredientCategory::factory()->create([ + 'name' => 'Test cat', + 'description' => 'Test cat desc', + ]); + + $response = $this->getJson('/api/ingredient-categories/' . $cat->id); + + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('data') + ->has('data.id') + ->where('data.name', 'Test cat') + ->where('data.description', 'Test cat desc') + ->etc() + ); + + $response->assertValidResponse(); + } + + public function test_create_category_response() + { + $response = $this->postJson('/api/ingredient-categories/', [ + 'name' => 'Test cat', + 'description' => 'Test cat desc', + ]); + + $response->assertCreated(); + $this->assertNotEmpty($response->headers->get('Location')); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('data') + ->has('data.id') + ->where('data.name', 'Test cat') + ->where('data.description', 'Test cat desc') + ->etc() + ); + + $response->assertValidRequest(); + $response->assertValidResponse(); + } + + public function test_update_category_response() + { + $cat = IngredientCategory::factory()->create([ + 'name' => 'Start cat', + 'description' => 'Start cat desc', + ]); + + $response = $this->putJson('/api/ingredient-categories/' . $cat->id, [ + 'name' => 'Test cat', + 'description' => 'Test cat desc', + ]); + + $response->assertSuccessful(); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('data') + ->where('data.id', $cat->id) + ->where('data.name', 'Test cat') + ->where('data.description', 'Test cat desc') + ->etc() + ); + + $response->assertValidRequest(); + $response->assertValidResponse(); + } + + public function test_delete_category_response() + { + $cat = IngredientCategory::factory()->create([ + 'name' => 'Start cat', + 'description' => 'Start cat desc', + ]); + + $response = $this->delete('/api/ingredient-categories/' . $cat->id); + + $response->assertNoContent(); + + $response->assertValidResponse(); + + $this->assertDatabaseMissing('ingredient_categories', ['id' => $cat->id]); + } +} diff --git a/tests/Feature/IngredientControllerTest.php b/tests/Feature/IngredientControllerTest.php index ae5bde30..c89b5db1 100644 --- a/tests/Feature/IngredientControllerTest.php +++ b/tests/Feature/IngredientControllerTest.php @@ -1,8 +1,10 @@ actingAs( User::factory()->create() ); @@ -30,6 +34,8 @@ public function test_list_ingredients_response() $response->assertStatus(200); $response->assertJsonCount(5, 'data'); + + $response->assertValidResponse(); } public function test_ingredient_show_response() @@ -50,6 +56,22 @@ public function test_ingredient_show_response() $response->assertJsonPath('data.name', 'Test ingredient'); $response->assertJsonPath('data.strength', 45.5); $response->assertJsonPath('data.description', 'Test'); + + $response->assertValidResponse(); + } + + public function test_ingredient_show_not_found_response() + { + $response = $this->getJson('/api/ingredients/404'); + + $response->assertStatus(404); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('message') + ->etc() + ); + + $response->assertValidResponse(404); } public function test_ingredient_store_response() @@ -78,6 +100,26 @@ public function test_ingredient_store_response() ->where('data.ingredient_category_id', $ingCat->id) ->etc() ); + + $response->assertValidRequest(); + $response->assertValidResponse(201); + } + + public function test_ingredient_store_fails_validation_response() + { + $response = $this->postJson('/api/ingredients', [ + 'strength' => 12.2, + ]); + + $response->assertStatus(422); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('message') + ->has('errors') + ->etc() + ); + + $response->assertValidResponse(422); } public function test_ingredient_update_response() @@ -100,7 +142,7 @@ public function test_ingredient_update_response() 'parent_ingredient_id' => null ]); - $response->assertStatus(200); + $response->assertSuccessful(); $response->assertJson(fn (AssertableJson $json) => $json ->has('data.id') @@ -109,6 +151,35 @@ public function test_ingredient_update_response() ->where('data.description', 'Description text') ->etc() ); + + $response->assertValidRequest(); + $response->assertValidResponse(200); + } + + public function test_ingredient_update_fails_validation_response() + { + + $ing = Ingredient::factory() + ->state([ + 'name' => 'Test ingredient', + 'strength' => 45.5, + 'description' => 'Test' + ]) + ->create(); + + $response = $this->putJson('/api/ingredients/' . $ing->id, [ + 'strength' => 12.2, + ]); + + $response->assertStatus(422); + $response->assertJson(fn (AssertableJson $json) => + $json + ->has('message') + ->has('errors') + ->etc() + ); + + $response->assertValidResponse(422); } public function test_ingredient_delete_response() @@ -123,7 +194,7 @@ public function test_ingredient_delete_response() $response = $this->deleteJson('/api/ingredients/' . $ing->id); - $response->assertStatus(200); + $response->assertNoContent(); $this->assertDatabaseMissing('ingredients', ['id' => $ing->id]); } } diff --git a/tests/Feature/ServerControllerTest.php b/tests/Feature/ServerControllerTest.php new file mode 100644 index 00000000..401e99ec --- /dev/null +++ b/tests/Feature/ServerControllerTest.php @@ -0,0 +1,40 @@ +getJson('/api/server/version'); + + $response->assertStatus(200); + + $this->assertSame('Bar Assistant', $response['data']['name']); + $this->assertNotNull($response['data']['version']); + $this->assertNotNull($response['data']['meilisearch_host']); + $this->assertNotNull($response['data']['meilisearch_version']); + + $response->assertValidResponse(); + } + + public function test_openapi_response() + { + $response = $this->getJson('/api/server/openapi'); + + $response->assertStatus(200); + + $response->assertValidResponse(); + } +}