From 2f4f40986c52eb8ff1d283e5d8cd004d43a2db2f Mon Sep 17 00:00:00 2001 From: "Emma C. Hughes" <84008144+emmachughes@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:37:41 +0100 Subject: [PATCH 1/3] add api endpoints for context --- .../Controllers/Api/ContentController.php | 2 + .../Controllers/Api/ContextController.php | 54 ++++++++++++++++ .../app/Http/Requests/Api/ContentRequest.php | 17 +++++ .../app/Http/Requests/Api/ContextRequest.php | 22 +++++++ sourcecode/hub/app/Models/Context.php | 2 + .../app/Transformers/ContentTransformer.php | 8 +++ .../app/Transformers/ContextTransformer.php | 25 ++++++++ sourcecode/hub/routes/api.php | 19 ++++++ .../hub/tests/Feature/Api/ContentTest.php | 16 +++++ .../hub/tests/Feature/Api/ContextTest.php | 64 +++++++++++++++++++ 10 files changed, 229 insertions(+) create mode 100644 sourcecode/hub/app/Http/Controllers/Api/ContextController.php create mode 100644 sourcecode/hub/app/Http/Requests/Api/ContextRequest.php create mode 100644 sourcecode/hub/app/Transformers/ContextTransformer.php create mode 100644 sourcecode/hub/tests/Feature/Api/ContextTest.php diff --git a/sourcecode/hub/app/Http/Controllers/Api/ContentController.php b/sourcecode/hub/app/Http/Controllers/Api/ContentController.php index e2dc9dcc0..21600d946 100644 --- a/sourcecode/hub/app/Http/Controllers/Api/ContentController.php +++ b/sourcecode/hub/app/Http/Controllers/Api/ContentController.php @@ -54,6 +54,8 @@ public function store(ContentRequest $request): JsonResponse $content->fill($request->validated()); $content->saveOrFail(); + $content->contexts()->sync($request->getContexts()); + foreach ($request->getRoles() as ['user' => $user, 'role' => $role]) { $content->users()->attach($user, ['role' => $role]); } diff --git a/sourcecode/hub/app/Http/Controllers/Api/ContextController.php b/sourcecode/hub/app/Http/Controllers/Api/ContextController.php new file mode 100644 index 000000000..36f6fe03d --- /dev/null +++ b/sourcecode/hub/app/Http/Controllers/Api/ContextController.php @@ -0,0 +1,54 @@ +transformWith($this->contextTransformer) + ->paginateWith(new IlluminatePaginatorAdapter($contexts)) + ->respond(); + } + + public function show(Context $context): JsonResponse + { + return fractal($context) + ->transformWith($this->contextTransformer) + ->respond(); + } + + public function store(ContextRequest $request): JsonResponse + { + $context = new Context($request->validated()); + $context->save(); + + return fractal($context) + ->transformWith($this->contextTransformer) + ->respond(); + } + + public function destroy(Context $context): Response + { + $context->delete(); + + return response()->noContent(); + } +} diff --git a/sourcecode/hub/app/Http/Requests/Api/ContentRequest.php b/sourcecode/hub/app/Http/Requests/Api/ContentRequest.php index e4b3cb690..d2a86a29e 100644 --- a/sourcecode/hub/app/Http/Requests/Api/ContentRequest.php +++ b/sourcecode/hub/app/Http/Requests/Api/ContentRequest.php @@ -5,10 +5,12 @@ namespace App\Http\Requests\Api; use App\Enums\ContentRole; +use App\Models\Context; use App\Models\User; use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use function array_map; final class ContentRequest extends FormRequest { @@ -20,6 +22,10 @@ public function rules(Gate $gate): array return [ 'shared' => ['sometimes', 'boolean'], + 'contexts' => [ + Rule::exists(Context::class, 'name'), + ], + 'created_at' => [ Rule::prohibitedIf(fn() => $gate->denies('admin')), 'sometimes', @@ -47,6 +53,17 @@ public function rules(Gate $gate): array ]; } + /** + * @return array + */ + public function getContexts(): array + { + return array_map( + fn(string $name) => Context::where('name', $name)->firstOrFail(), + $this->validated('contexts', []), + ); + } + /** * @return array */ diff --git a/sourcecode/hub/app/Http/Requests/Api/ContextRequest.php b/sourcecode/hub/app/Http/Requests/Api/ContextRequest.php new file mode 100644 index 000000000..73e203ccd --- /dev/null +++ b/sourcecode/hub/app/Http/Requests/Api/ContextRequest.php @@ -0,0 +1,22 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'regex:/^\w+$/', Rule::unique(Context::class, 'name')], + ]; + } +} diff --git a/sourcecode/hub/app/Models/Context.php b/sourcecode/hub/app/Models/Context.php index 0f1689bd3..190a725f5 100644 --- a/sourcecode/hub/app/Models/Context.php +++ b/sourcecode/hub/app/Models/Context.php @@ -20,6 +20,8 @@ class Context extends Model public const UPDATED_AT = null; + protected $perPage = 100; + protected $fillable = [ 'name', ]; diff --git a/sourcecode/hub/app/Transformers/ContentTransformer.php b/sourcecode/hub/app/Transformers/ContentTransformer.php index 71ce60a92..669145178 100644 --- a/sourcecode/hub/app/Transformers/ContentTransformer.php +++ b/sourcecode/hub/app/Transformers/ContentTransformer.php @@ -13,18 +13,21 @@ final class ContentTransformer extends TransformerAbstract { /** @var string[] */ protected array $availableIncludes = [ + 'contexts', 'roles', 'versions', ]; /** @var string[] */ protected array $defaultIncludes = [ + 'contexts', 'roles', 'versions', ]; public function __construct( private readonly ContentVersionTransformer $contentVersionTransformer, + private readonly ContextTransformer $contextTransformer, ) {} /** @@ -45,6 +48,11 @@ public function transform(Content $content): array ]; } + public function includeContexts(Content $content): Collection + { + return $this->collection($content->contexts, $this->contextTransformer); + } + public function includeRoles(Content $content): Collection { return $this->collection($content->users, function (User $user) { diff --git a/sourcecode/hub/app/Transformers/ContextTransformer.php b/sourcecode/hub/app/Transformers/ContextTransformer.php new file mode 100644 index 000000000..32e4420e8 --- /dev/null +++ b/sourcecode/hub/app/Transformers/ContextTransformer.php @@ -0,0 +1,25 @@ + + */ + public function transform(Context $context): array + { + return [ + 'id' => $context->id, + 'name' => $context->name, + 'links' => [ + 'self' => route('api.contexts.show', [$context]), + ], + ]; + } +} diff --git a/sourcecode/hub/routes/api.php b/sourcecode/hub/routes/api.php index ac357f996..3492f0d92 100644 --- a/sourcecode/hub/routes/api.php +++ b/sourcecode/hub/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\ContentController; use App\Http\Controllers\Api\ContentVersionController; use App\Http\Controllers\Api\ContentViewController; +use App\Http\Controllers\Api\ContextController; use App\Http\Controllers\Api\LtiToolController; use App\Http\Controllers\Api\UserController; use Illuminate\Support\Facades\Route; @@ -65,6 +66,24 @@ ->can('edit', 'apiContent'); }); +Route::whereUlid('context')->name('api.contexts.')->middleware(['can:admin'])->group(function () { + Route::get('/contexts') + ->uses([ContextController::class, 'index']) + ->name('index'); + + Route::get('/contexts/{context:name}') + ->uses([ContextController::class, 'show']) + ->name('show'); + + Route::post('/contexts') + ->uses([ContextController::class, 'store']) + ->name('create'); + + Route::delete('/contexts/{context:name}') + ->uses([ContextController::class, 'destroy']) + ->name('destroy'); +}); + Route::whereUlid('tool')->name('api.lti-tools.')->middleware(['can:admin'])->group(function () { Route::get('/lti-tools') ->uses([LtiToolController::class, 'index']); diff --git a/sourcecode/hub/tests/Feature/Api/ContentTest.php b/sourcecode/hub/tests/Feature/Api/ContentTest.php index 961fd5ab9..f343aca72 100644 --- a/sourcecode/hub/tests/Feature/Api/ContentTest.php +++ b/sourcecode/hub/tests/Feature/Api/ContentTest.php @@ -7,6 +7,7 @@ use App\Models\Content; use App\Models\ContentVersion; use App\Models\ContentView; +use App\Models\Context; use App\Models\LtiTool; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -163,11 +164,15 @@ public function testShowsContent(): void public function testStoresContent(): void { + $context = Context::factory()->name('my_context')->create(); $owner = User::factory()->create(); $data = [ 'created_at' => $this->faker->dateTime->format('c'), 'shared' => $this->faker->boolean, + 'contexts' => [ + 'my_context', + ], 'roles' => [ [ 'user' => $owner->id, @@ -196,6 +201,17 @@ public function testStoresContent(): void ->where('shared', $data['shared']) ->has('links.self') ->has('versions') + ->where('contexts', [ + 'data' => [ + [ + 'id' => $context->id, + 'name' => 'my_context', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/my_context', + ], + ], + ], + ]) ->where('roles', [ 'data' => [ [ diff --git a/sourcecode/hub/tests/Feature/Api/ContextTest.php b/sourcecode/hub/tests/Feature/Api/ContextTest.php new file mode 100644 index 000000000..e150bfc95 --- /dev/null +++ b/sourcecode/hub/tests/Feature/Api/ContextTest.php @@ -0,0 +1,64 @@ +admin()->create(); + $id1 = Context::factory()->name('context_1')->create()->id; + $id2 = Context::factory()->name('context_2')->create()->id; + $id3 = Context::factory()->name('context_3')->create()->id; + + $this + ->withBasicAuth($user->getApiKey(), $user->getApiSecret()) + ->getJson('https://hub-test.edlib.test/api/contexts') + ->assertOk() + ->assertJson(fn(AssertableJson $json) => $json + ->where('data', [ + [ + 'id' => $id1, + 'name' => 'context_1', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/context_1', + ], + ], + [ + 'id' => $id2, + 'name' => 'context_2', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/context_2', + ], + ], + [ + 'id' => $id3, + 'name' => 'context_3', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/context_3', + ], + ], + ]) + ->where('meta', [ + 'pagination' => [ + 'count' => 3, + 'current_page' => 1, + 'links' => [], + 'per_page' => 100, + 'total' => 3, + 'total_pages' => 1, + ], + ]) + ); + } +} From 8e06f4939406c07b6094bcfa17f4c2c852edfb06 Mon Sep 17 00:00:00 2001 From: "Emma C. Hughes" <84008144+emmachughes@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:44:03 +0100 Subject: [PATCH 2/3] fix test namespace --- sourcecode/hub/tests/Feature/Api/ContextTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sourcecode/hub/tests/Feature/Api/ContextTest.php b/sourcecode/hub/tests/Feature/Api/ContextTest.php index e150bfc95..68017198c 100644 --- a/sourcecode/hub/tests/Feature/Api/ContextTest.php +++ b/sourcecode/hub/tests/Feature/Api/ContextTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Feature\Api; +namespace Tests\Feature\Api; use App\Models\Context; use App\Models\User; From 523877e6641a14409228266f025c4071abd6c373 Mon Sep 17 00:00:00 2001 From: "Emma C. Hughes" <84008144+emmachughes@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:59:24 +0100 Subject: [PATCH 3/3] delete contexts via api --- sourcecode/hub/app/Events/ContextDeleting.php | 12 +++ .../Controllers/Api/ContextController.php | 1 + .../app/Http/Requests/Api/ContentRequest.php | 1 + .../hub/app/Listeners/ContextListener.php | 17 ++++ sourcecode/hub/app/Models/Context.php | 25 ++++++ sourcecode/hub/routes/api.php | 2 +- .../hub/tests/Feature/Api/ContextTest.php | 83 ++++++++++++------- 7 files changed, 109 insertions(+), 32 deletions(-) create mode 100644 sourcecode/hub/app/Events/ContextDeleting.php create mode 100644 sourcecode/hub/app/Listeners/ContextListener.php diff --git a/sourcecode/hub/app/Events/ContextDeleting.php b/sourcecode/hub/app/Events/ContextDeleting.php new file mode 100644 index 000000000..154b985fc --- /dev/null +++ b/sourcecode/hub/app/Events/ContextDeleting.php @@ -0,0 +1,12 @@ +context->contents()->detach(); + + $event->context->platforms()->detach(); + } +} diff --git a/sourcecode/hub/app/Models/Context.php b/sourcecode/hub/app/Models/Context.php index 190a725f5..7568e95d7 100644 --- a/sourcecode/hub/app/Models/Context.php +++ b/sourcecode/hub/app/Models/Context.php @@ -4,10 +4,12 @@ namespace App\Models; +use App\Events\ContextDeleting; use App\Support\HasUlidsFromCreationDate; use Database\Factories\ContextFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** * A "context" is used to grant access based on externally defined criteria. @@ -25,4 +27,27 @@ class Context extends Model protected $fillable = [ 'name', ]; + + /** + * @var array + */ + protected $dispatchesEvents = [ + 'deleting' => ContextDeleting::class, + ]; + + /** + * @return BelongsToMany + */ + public function contents(): BelongsToMany + { + return $this->belongsToMany(Content::class, 'content_context'); + } + + /** + * @return BelongsToMany + */ + public function platforms(): BelongsToMany + { + return $this->belongsToMany(LtiPlatform::class, 'lti_platform_context'); + } } diff --git a/sourcecode/hub/routes/api.php b/sourcecode/hub/routes/api.php index 3492f0d92..4681491a3 100644 --- a/sourcecode/hub/routes/api.php +++ b/sourcecode/hub/routes/api.php @@ -66,7 +66,7 @@ ->can('edit', 'apiContent'); }); -Route::whereUlid('context')->name('api.contexts.')->middleware(['can:admin'])->group(function () { +Route::where(['context' => '\w+'])->name('api.contexts.')->middleware(['can:admin'])->group(function () { Route::get('/contexts') ->uses([ContextController::class, 'index']) ->name('index'); diff --git a/sourcecode/hub/tests/Feature/Api/ContextTest.php b/sourcecode/hub/tests/Feature/Api/ContextTest.php index 68017198c..206865c7a 100644 --- a/sourcecode/hub/tests/Feature/Api/ContextTest.php +++ b/sourcecode/hub/tests/Feature/Api/ContextTest.php @@ -4,7 +4,10 @@ namespace Tests\Feature\Api; +use App\Enums\ContentRole; +use App\Models\Content; use App\Models\Context; +use App\Models\LtiPlatform; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Testing\Fluent\AssertableJson; @@ -25,40 +28,58 @@ public function testListsContexts(): void ->withBasicAuth($user->getApiKey(), $user->getApiSecret()) ->getJson('https://hub-test.edlib.test/api/contexts') ->assertOk() - ->assertJson(fn(AssertableJson $json) => $json - ->where('data', [ - [ - 'id' => $id1, - 'name' => 'context_1', - 'links' => [ - 'self' => 'https://hub-test.edlib.test/api/contexts/context_1', + ->assertJson( + fn(AssertableJson $json) => $json + ->where('data', [ + [ + 'id' => $id1, + 'name' => 'context_1', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/context_1', + ], ], - ], - [ - 'id' => $id2, - 'name' => 'context_2', - 'links' => [ - 'self' => 'https://hub-test.edlib.test/api/contexts/context_2', + [ + 'id' => $id2, + 'name' => 'context_2', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/context_2', + ], ], - ], - [ - 'id' => $id3, - 'name' => 'context_3', - 'links' => [ - 'self' => 'https://hub-test.edlib.test/api/contexts/context_3', + [ + 'id' => $id3, + 'name' => 'context_3', + 'links' => [ + 'self' => 'https://hub-test.edlib.test/api/contexts/context_3', + ], ], - ], - ]) - ->where('meta', [ - 'pagination' => [ - 'count' => 3, - 'current_page' => 1, - 'links' => [], - 'per_page' => 100, - 'total' => 3, - 'total_pages' => 1, - ], - ]) + ]) + ->where('meta', [ + 'pagination' => [ + 'count' => 3, + 'current_page' => 1, + 'links' => [], + 'per_page' => 100, + 'total' => 3, + 'total_pages' => 1, + ], + ]), ); } + + public function testDeletesContext(): void + { + $context = Context::factory()->name('to_be_gone')->create(); + + // ensure we can delete it even when referenced + Content::factory()->withContext($context)->create(); + LtiPlatform::factory()->withContext($context, ContentRole::Owner)->create(); + + $user = User::factory()->admin()->create(); + + $this->withBasicAuth($user->getApiKey(), $user->getApiSecret()) + ->deleteJson('https://hub-test.edlib.test/api/contexts/to_be_gone') + ->assertNoContent(); + + $this->assertDatabaseMissing($context); + } }