Repository navigation
Full API (JSON and GraphQL) #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
66 commits
Select commit
Hold shift + click to select a range
2126d99
feat(api): add CORS support via corsica
mwoods79 103b12d
test: add HuddlzWeb.ApiCase template for API tests
mwoods79 f399c50
feat(accounts): add User.public_profile read action
mwoods79 ec43992
feat(api): add Api.AuthController register endpoint
mwoods79 7bec52d
feat(api): add Api.AuthController sign_in endpoint
mwoods79 34b402c
feat(api): add Api.AuthController me endpoint
mwoods79 01cebb3
feat(api): add Api.AuthController sign_out endpoint
mwoods79 33c9d74
feat(api): add Api.AuthController password_reset endpoint
mwoods79 577f16d
feat(accounts): add ApiKey resource and api_key strategy
mwoods79 31a7827
feat(api): wire ApiKey.Plug into :graphql pipeline
mwoods79 5f3818f
feat(api): add api_keys create endpoint
mwoods79 360c239
feat(api): add api_keys list endpoint
mwoods79 e65628f
feat(api): add api_keys delete endpoint
mwoods79 d9d362b
feat(api): expose Huddl.upcoming via GraphQL and JSON:API
mwoods79 df66a28
feat(api): expose Huddl.past via GraphQL and JSON:API
mwoods79 59f9ca0
feat(api): expose Huddl.by_group via GraphQL and JSON:API
mwoods79 49f8c52
refactor(huddl)!: reshape rsvp action and expose as mutation
mwoods79 b245735
test(geocoding): silence GeocodingStub warning spam
mwoods79 6d4338f
refactor(huddl)!: reshape cancel_rsvp action and expose
mwoods79 7735c92
feat(api): expose Huddl.destroy via GraphQL and JSON:API
mwoods79 50b014d
feat(api): expose Group resource via GraphQL and JSON:API
mwoods79 f9e11c4
feat(api): expose Group.search via GraphQL and JSON:API
mwoods79 af87819
refactor(group)!: reshape get_by_owner as my_groups
mwoods79 43a7224
feat(api): expose Group.create_group mutation
mwoods79 6696a8f
feat(api): expose Group.update_details mutation
mwoods79 6da8937
feat(api): expose Group.destroy via GraphQL and JSON:API
mwoods79 8ce553c
feat(api): expose GroupMember.get_by_group
mwoods79 70f90fe
refactor(group_member)!: reshape get_by_user as my_memberships
mwoods79 40a233e
refactor(group_member)!: reshape join_group and expose mutation
mwoods79 2fabe9c
feat(api): expose GroupMember.leave_group
mwoods79 44fc156
feat(api): expose GroupMember.add_member
mwoods79 c847387
feat(api): expose GroupMember.remove_member mutation
mwoods79 06cc63d
feat(api): expose HuddlAttendee.by_huddl
mwoods79 e0731e7
refactor(huddl_attendee)!: reshape by_user as my_rsvps
mwoods79 edb0384
refactor(huddl_attendee)!: reshape check_rsvp action
mwoods79 80f55d8
feat(api): expose GroupLocation.by_group
mwoods79 5e3b1a5
feat(api): expose GroupLocation create/update/destroy
mwoods79 e95b40d
test(api): add sensitive-field introspection audit
mwoods79 0337245
feat(storage): add Storage.Changes.PersistUpload + :upload actions
mwoods79 ee0eeab
feat(api): expose HuddlImage and GroupImage upload via API
mwoods79 b7c899c
feat(api): expose User.me GraphQL query
mwoods79 a827e4b
feat(api): expose User update mutations via GraphQL
mwoods79 9dbf743
test(api): end-to-end happy-path scenario
mwoods79 6409dc8
test(api): refine sensitive-field audit for User type
mwoods79 8a4f3ec
docs(api): catalog deliberately deferred follow-ups
mwoods79 df8f31f
fix(group)!: prevent owner_id spoofing in create_group
mwoods79 7fa2ede
fix(api): mark user-facing attributes public
mwoods79 aec8a73
fix(api): clamp expires_in_days on api_keys create
mwoods79 ae354d5
fix(test): make sensitive-fields audit fail loudly on new user types
mwoods79 80a53bb
fix(test): drop residual owner_id from LiveView test maps
mwoods79 7fec7fb
fix(group)!: honor caller-supplied slug on create_group
mwoods79 bfe7828
feat(group)!: cascade group destroy through dependents
mwoods79 22bcf88
feat(api): expose ProfilePicture upload via JSON:API + GraphQL
mwoods79 42d3ccb
feat(api): expose remove_member via JSON:API
mwoods79 ce88df3
test(api): exercise multipart image upload end-to-end via HTTP
mwoods79 3fc9667
fix(api): map storage atom errors to friendly messages
mwoods79 3337cad
docs(api): close out followups addressed in shippability sweep
mwoods79 2370ea6
test(api): drop dead setup in JSON:API rsvp test
mwoods79 82c78e1
docs(api): record three deferred items from review
mwoods79 7eb5a58
fix(api): default CORS_ORIGINS to PHX_HOST
mwoods79 45eb586
fix(api): skip ApiKey plug when actor already set
mwoods79 dacab33
refactor(api): test Cors matching against explicit origins
mwoods79 a511410
fix(api): require user on api keys, cascade delete, add index
mwoods79 42012b6
fix(api): error when upload is missing parent_id
mwoods79 51c710a
docs(api): note flaky profile_picture.feature step
mwoods79 5a29ff8
test: keep geocoding stub honest with the contract
mwoods79 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| # API follow-ups | ||
|
|
||
| Things consciously deferred while landing the JSON:API + GraphQL surface on | ||
| `feat/api`. Each entry captures *why* it was skipped and *what to do* when | ||
| it's time to come back to it, so we don't have to reconstruct the context. | ||
|
|
||
| > **Status as of latest sweep:** | ||
| > Items addressed in the "make `feat/api` shippable" pass: | ||
| > the slug ergonomics fix, group destroy cascade, ProfilePicture upload | ||
| > exposure, the GroupMember.remove_member JSON:API route, the real | ||
| > multipart HTTP test, and the image-upload error mapping. Removed from | ||
| > this doc. The remaining items below are genuinely deferred. | ||
|
|
||
| ## Pagination sweep | ||
|
|
||
| **Status:** deferred. | ||
| **Why:** Adding `pagination keyset?: true` to a read action changes the | ||
| response shape — the result becomes a `KeysetPageOf<Type>` wrapper instead | ||
| of a bare list, so any test or call site that pattern-matches on | ||
| `{:ok, [list]}` breaks. The actions that ship paginated already (`Huddl.search`, | ||
| `Huddl.by_group`) needed their tests written around `results { ... }` | ||
| unwrapping. Sweeping the rest at the same time would have multiplied | ||
| breakage well beyond the API change. | ||
| **To do:** | ||
|
|
||
| - Add `pagination keyset?: true, offset?: true, countable: true, required?: false` | ||
| to: `Huddl.upcoming`, `Huddl.past`, `Group.read`, `Group.search`, | ||
| `GroupMember.get_by_group`, `GroupMember.get_by_user`, | ||
| `HuddlAttendee.by_huddl`, `HuddlAttendee.by_user`, | ||
| `GroupLocation.by_group`. | ||
| - Update each action's tests to wrap result-set assertions in `results { … }` | ||
| (GraphQL) or expect `meta.page` envelopes (JSON:API). | ||
| - Add a per-resource `default_limit` (20) and a max so large lists don't | ||
| fall back to "stream the whole table". | ||
|
|
||
| ## OpenAPI / GraphiQL prose polish | ||
|
|
||
| **Status:** deferred. | ||
| **Why:** Descriptions exist on actions and arguments but they were written | ||
| for resource-internal callers. The phrasing reads awkwardly when surfaced | ||
| to API consumers via Swagger UI or GraphiQL. This is a writing task more | ||
| than a code task. | ||
| **To do:** | ||
|
|
||
| - Walk through `/api/json/swaggerui` and rewrite descriptions on each | ||
| exposed action to address API consumers ("Create a huddl" not | ||
| "Create a huddl with the given attributes via the create action"). | ||
| - Add `@deprecated` directives on the legacy `:create` actions for | ||
| `HuddlImage` / `GroupImage` (they're now superseded by `:upload` for | ||
| API use; the manual-create actions should stay for internal callers | ||
| that pre-compute storage paths). | ||
| - Add example bodies to the OpenAPI spec via `OpenApiSpex` example | ||
| schemas. | ||
|
|
||
| ## User update mutations use `id:` arg | ||
|
|
||
| **Status:** functional but un-ergonomic. Attempted fix in the | ||
| shippable-API sweep; deferred again after the AshGraphql/Ash 3.x | ||
| interaction proved more involved than expected. | ||
| **Why:** `updateDisplayName(id: $id, input: { displayName: $name })` | ||
| takes the user id explicitly even though the policy already | ||
| restricts the action to `^actor(:id)`. The client can pass any id | ||
| and the policy rejects mismatches; passing the id is noise. | ||
|
|
||
| The clean fix — `update :update_display_name, :update_display_name, | ||
| read_action: :me, identity: false` — produces an Ecto error at run | ||
| time: | ||
|
|
||
| > `update_all` does not allow subqueries in `from` | ||
|
|
||
| because `:me` filters on `^actor(:id)`, which Ash's atomic update | ||
| path turns into a subquery wrapping the update statement. Postgres | ||
| rejects that. Setting `require_atomic? false` on the update action | ||
| didn't bypass the path either. | ||
|
|
||
| **To do:** | ||
|
|
||
| - Investigate AshGraphql 1.9's mutation pipeline and figure out | ||
| whether there's a `read_action`-compatible shape that doesn't go | ||
| through bulk update (a `before_action` lookup, or a custom | ||
| resolver, or an Ash patch). | ||
| - Alternative: build a generic action wrapper (`update_self`) that | ||
| takes only the input fields, loads the actor explicitly, and | ||
| delegates to the underlying update. Expose that action as the | ||
| mutation. More code than the `read_action` shortcut but works | ||
| around the atomic-update constraint. | ||
|
|
||
| ## API key strategy: scopes, rotation, audit | ||
|
|
||
| **Status:** v1 ships with full-permission keys and manual revocation. | ||
| **Why:** Sufficient for a single first-party SPA / one trusted | ||
| machine consumer. Adding scopes / fine-grained permissions before | ||
| there's a second consumer is premature. | ||
| **To do (when needed):** | ||
|
|
||
| - Per-key scopes: `read_only`, `events:write`, `groups:admin`. Store | ||
| on the `ApiKey` resource as an array of atoms; check via a custom | ||
| policy. | ||
| - Rotation: a `rotate_api_key` mutation that creates a new key and | ||
| marks the old one for deletion in N days. | ||
| - Audit log: record every API-key authentication event so a | ||
| compromised key can be traced. | ||
|
|
||
| ## Rate limiting | ||
|
|
||
| **Status:** none. | ||
| **Why:** No threat model yet — only first-party clients in flight. | ||
| **To do:** | ||
|
|
||
| - Add `:hammer` (Redis-backed in prod, ETS in dev) plug in front of | ||
| `/api/auth/*` first (brute-force protection on register and | ||
| sign_in). | ||
| - Per-user-or-key buckets on `/api/json/*` and `/gql`. Start | ||
| permissive — the goal is to stop runaway scripts, not to throttle | ||
| legitimate use. | ||
|
|
||
| ## RemoveMemberByIds membership-existence leak | ||
|
|
||
| **Status:** known, acceptable for v1. | ||
| **Why:** `Huddlz.Communities.GroupMember.Actions.RemoveMemberByIds` | ||
| does `Ash.read_one(authorize?: false)` on `(group_id, user_id)` and | ||
| then dispatches `:remove_member` with the actor. The destroy is the | ||
| real auth gate, so unauthorized callers can't change state — but the | ||
| response shape distinguishes "membership not found" (404) from | ||
| "found but forbidden" (403), letting any authenticated actor probe | ||
| membership pairs. Group membership in a public group isn't very | ||
| secret; private groups would leak. | ||
| **To do:** | ||
|
|
||
| - Fold into the API-key scopes work above. Once a `groups:admin` | ||
| scope exists, the lookup can be filtered to "groups owned by the | ||
| actor" and the probe closes naturally. | ||
| - Cheaper interim fix: collapse 404 and 403 to a single 404 response | ||
| shape so the failure modes are indistinguishable. | ||
|
|
||
| ## User-FK cascade asymmetry | ||
|
|
||
| **Status:** deferred. | ||
| **Why:** Migration `20260427030225_cascade_destroy_groups_and_huddls` | ||
| cascades `groups → group_members / group_images / huddlz` and | ||
| `huddlz → huddl_images / huddl_attendees`, but the three FKs back to | ||
| `users` are **not** cascaded: `group_members.user_id`, | ||
| `huddl_attendees.user_id`, `huddlz.creator_id`. There's no | ||
| delete-account flow today, so this isn't blocking. | ||
| **To do (when delete-account ships):** | ||
|
|
||
| - Follow-up migration. `group_members` and `huddl_attendees` are | ||
| pure join rows — `on_delete: :delete_all` is fine. | ||
| - `huddlz.creator_id` needs a product call **before** the FK | ||
| changes: either soft-delete and anonymize the creator, or | ||
| transfer ownership to the group owner. Don't `:delete_all` huddlz | ||
| when their creator deletes their account. | ||
|
|
||
| ## sign_out path doesn't apply to API-key auth | ||
|
|
||
| **Status:** functional but UX-confusing. | ||
| **Why:** `DELETE /api/auth/sign_out` only revokes JWT bearers | ||
| (it calls `TokenResource.Actions.revoke/2`). An actor authenticated | ||
| via an API key gets the same generic | ||
| `{"error": "Authentication required"}` 401 as an unauthenticated | ||
| caller, with no hint that the real revoke endpoint is | ||
| `DELETE /api/auth/api_keys/:id`. | ||
| **To do (when there's a real API-key consumer):** | ||
|
|
||
| - Detect the auth method on the conn and either | ||
| (a) return a 400 with a pointer to `/api/auth/api_keys/:id`, or | ||
| (b) destroy the active API key when sign_out is called with one. | ||
| (a) is simpler and probably better — sign_out and key revocation | ||
| are conceptually different. | ||
|
|
||
| ## Cucumber `.feature` for the happy path | ||
|
|
||
| **Status:** end-to-end test ships as a single ExUnit case | ||
| (`test/huddlz_web/api/end_to_end_test.exs`). | ||
| **Why:** A Cucumber `.feature` file is the more readable format | ||
| for product/QA review, but writing the step definitions for a | ||
| multi-bearer, multi-protocol flow (JSON:API + GraphQL with token | ||
| swaps) is its own project. | ||
| **To do:** | ||
|
|
||
| - Port `end_to_end_test.exs` to a `.feature` file with step | ||
| definitions. Probably easier once we have a small step library | ||
| for `Given I am authenticated as ...` / `When I POST ... with body ...` / | ||
| `Then the response is ...`. | ||
|
|
||
| ## Flaky `profile_picture.feature` member-avatar step | ||
|
|
||
| **Status:** intermittent failure observed during the PR-113 review | ||
| sweep. | ||
| **Why:** The Cucumber scenario "Profile picture appears in group | ||
| members section" (`test/features/profile_picture.feature:45`) fails | ||
| the `Then I should see the member avatar with image` step under some | ||
| seeds — the selector `main img[src*='_thumb.jpg']` finds nothing. | ||
| Re-running the same file with `--seed 0` is green, so it's a timing | ||
| or fixture-ordering flake rather than a bug in the assertion. Looks | ||
| unrelated to feat/api code, but it surfaces while the suite is hot | ||
| on the branch. | ||
| **To do:** | ||
|
|
||
| - Reproduce with a sticky seed: bisect via `--seed <n>` to find a | ||
| failing one, then capture the rendered HTML at the failing step. | ||
| - Likely culprits: profile-picture upload running through a Mox | ||
| expectation that another async test consumed first, or | ||
| `current_profile_picture_url` aggregate not having loaded by the | ||
| time the Liveview renders. The `Mox.stub_with` setup in | ||
| `test/features/support/hooks.exs` is the place to verify. | ||
| - Once root-caused, either pin the scenario's stubs explicitly or | ||
| await the avatar render in the step definition before asserting. | ||
|
|
||
| --- | ||
|
|
||
| When picking one of these up, tag the commit message with the section | ||
| heading so we can crosswalk back to this doc. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| defmodule Huddlz.Accounts.ApiKey do | ||
| @moduledoc """ | ||
| API keys for machine-to-machine authentication. | ||
|
|
||
| Plaintext keys are returned only at create time via the | ||
| `plaintext_api_key` metadata; only the hash is persisted. | ||
| """ | ||
|
|
||
| use Ash.Resource, | ||
| otp_app: :huddlz, | ||
| domain: Huddlz.Accounts, | ||
| data_layer: AshPostgres.DataLayer, | ||
| authorizers: [Ash.Policy.Authorizer] | ||
|
|
||
| postgres do | ||
| table "api_keys" | ||
| repo Huddlz.Repo | ||
|
|
||
| references do | ||
| reference :user, on_delete: :delete | ||
| end | ||
|
|
||
| custom_indexes do | ||
| index [:user_id] | ||
| end | ||
| end | ||
|
|
||
| actions do | ||
| defaults [:read, :destroy] | ||
|
|
||
| create :create do | ||
| primary? true | ||
| accept [:expires_at] | ||
|
|
||
| change relate_actor(:user) | ||
|
|
||
| change {AshAuthentication.Strategy.ApiKey.GenerateApiKey, | ||
| prefix: :huddlz, hash: :api_key_hash} | ||
| end | ||
| end | ||
|
|
||
| policies do | ||
| bypass AshAuthentication.Checks.AshAuthenticationInteraction do | ||
| authorize_if always() | ||
| end | ||
|
|
||
| policy action(:create) do | ||
| description "Authenticated users can create API keys for themselves" | ||
| authorize_if actor_present() | ||
| end | ||
|
|
||
| policy action(:read) do | ||
| description "Users can read their own API keys" | ||
| authorize_if expr(user_id == ^actor(:id)) | ||
| end | ||
|
|
||
| policy action(:destroy) do | ||
| description "Users can revoke their own API keys" | ||
| authorize_if expr(user_id == ^actor(:id)) | end | |
| end | ||
|
|
||
| attributes do | ||
| uuid_primary_key :id | ||
|
|
||
| attribute :api_key_hash, :binary do | ||
| allow_nil? false | ||
| sensitive? true | ||
| end | ||
|
|
||
| attribute :expires_at, :utc_datetime_usec do | ||
| allow_nil? false | ||
| end | ||
| end | ||
|
|
||
| relationships do | ||
| belongs_to :user, Huddlz.Accounts.User do | ||
| allow_nil? false | ||
| end | ||
| end | ||
|
|
||
| calculations do | ||
| calculate :valid, :boolean, expr(expires_at > now()) | ||
| end | ||
|
|
||
| identities do | ||
| identity :unique_api_key, [:api_key_hash] | ||
| end | ||
| end |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should read this from PHX_HOST if possible.