diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9504a7f..9fa8fe6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -24,6 +24,16 @@ jobs: node-version: ${{ matrix.node-versions }} cache: 'npm' + - name: Cache Elm packages + uses: actions/cache@v4 + with: + path: | + ~/.elm + elm-stuff + key: ${{ runner.os }}-elm-${{ hashFiles('elm.json') }} + restore-keys: | + ${{ runner.os }}-elm- + - name: Install dependencies run: npm ci diff --git a/elm.json b/elm.json index d02673e..e63ab77 100644 --- a/elm.json +++ b/elm.json @@ -9,13 +9,17 @@ "WP.User", "WP.Http", "WP.Notice", - "WP.Clipboard" + "WP.Clipboard", + "WP.Post", + "WP.Term", + "WP.Tax" ], "elm-version": "0.19.0 <= v < 0.20.0", "dependencies": { "elm/core": "1.0.0 <= v < 2.0.0", "elm/json": "1.0.0 <= v < 2.0.0", "elm/http": "2.0.0 <= v < 3.0.0", + "elm/url": "1.0.0 <= v < 2.0.0", "NoRedInk/elm-json-decode-pipeline": "1.0.0 <= v < 2.0.0" }, "test-dependencies": { diff --git a/src/WP/Flags.elm b/src/WP/Flags.elm index 632580b..ec5d113 100644 --- a/src/WP/Flags.elm +++ b/src/WP/Flags.elm @@ -12,7 +12,7 @@ silently producing a stale model. import Json.Decode as D import Json.Decode.Pipeline exposing (required) -import WP.User as User exposing (User) +import WP.User.Type as User exposing (User) {-| Mirror of the PHP flags blob emitted by `Standard_Flags::build()` plus the diff --git a/src/WP/Post.elm b/src/WP/Post.elm new file mode 100644 index 0000000..1edc186 --- /dev/null +++ b/src/WP/Post.elm @@ -0,0 +1,287 @@ +module WP.Post exposing + ( Post + , DecodeKeys, defaultDecodeKeys + , decoder, taxonomyOf, metaOf + , Body, emptyBody, encodeBody + , ListParams, defaultListParams + , list, get, create, update, delete + ) + +{-| Typed read/write of `/wp/v2/posts` (and any custom post type). + +WordPress REST has two dynamic-schema dimensions on post objects: + + - **Taxonomies** — `categories`, `tags`, plus any custom taxonomies (e.g. + `genre`, `recipe_cuisine`) appear as top-level fields on the post. + - **Post meta** — keys registered via `register_post_meta(..., 'show_in_rest' + => true)` appear inside a `meta` object. + +Rather than hardcode either set, this module models both as dictionaries and +asks the caller which keys to extract via `DecodeKeys`. For the standard +`post` type with no registered meta, pass `{ taxonomies = ["categories", +"tags"], meta = [] }`. For a recipe CPT with custom taxonomies and meta keys, +pass `{ taxonomies = ["recipe_cuisine"], meta = ["_difficulty", "_servings"] }`. + +The `Post` record extracts the *rendered* values for `title`, `content` and +`excerpt`. If you need the raw markup, fetch with `?context=edit` and decode +the response yourself via `WP.Http.request`. + +@docs Post +@docs DecodeKeys, defaultDecodeKeys +@docs decoder, taxonomyOf, metaOf +@docs Body, emptyBody, encodeBody +@docs ListParams, defaultListParams +@docs list, get, create, update, delete +-} + +import Dict exposing (Dict) +import Json.Decode as D +import Json.Decode.Pipeline exposing (custom, optional, required) +import Json.Encode as E +import WP.Flags exposing (Flags) +import WP.Http +import WP.Query + + +{-| -} +type alias Post = + { id : Int + , date : String + , slug : String + , status : String + , link : String + , title : String + , content : String + , excerpt : String + , author : Int + , featuredMedia : Int + , taxonomies : Dict String (List Int) + , meta : Dict String D.Value + } + + +{-| Tells the decoder which dynamic-schema fields to extract from the response. + +`taxonomies` are top-level taxonomy keys (e.g. `["categories", "tags", +"genre"]`). Each becomes a `List Int` of term ids in `post.taxonomies`. + +`meta` are registered post-meta keys (e.g. `["_my_field", "_another"]`). Each +becomes a `Json.Decode.Value` in `post.meta` — caller decodes the value into +its specific shape. +-} +type alias DecodeKeys = + { taxonomies : List String + , meta : List String + } + + +{-| Empty `DecodeKeys` — no taxonomies, no meta extracted. -} +defaultDecodeKeys : DecodeKeys +defaultDecodeKeys = + { taxonomies = [], meta = [] } + + +{-| Build a decoder using the given `DecodeKeys`. -} +decoder : DecodeKeys -> D.Decoder Post +decoder keys = + D.succeed Post + |> required "id" D.int + |> required "date" D.string + |> required "slug" D.string + |> required "status" D.string + |> required "link" D.string + |> required "title" (D.field "rendered" D.string) + |> required "content" (D.field "rendered" D.string) + |> required "excerpt" (D.field "rendered" D.string) + |> required "author" D.int + |> optional "featured_media" D.int 0 + |> custom (taxonomiesDecoder keys.taxonomies) + |> custom (metaDecoder keys.meta) + + +taxonomiesDecoder : List String -> D.Decoder (Dict String (List Int)) +taxonomiesDecoder keys = + let + readKey key = + D.maybe (D.field key (D.list D.int)) + |> D.map (\m -> ( key, Maybe.withDefault [] m )) + in + keys + |> List.map readKey + |> List.foldr (D.map2 (::)) (D.succeed []) + |> D.map Dict.fromList + + +metaDecoder : List String -> D.Decoder (Dict String D.Value) +metaDecoder keys = + let + readKey key = + D.maybe (D.at [ "meta", key ] D.value) + |> D.map (\m -> ( key, Maybe.withDefault E.null m )) + in + keys + |> List.map readKey + |> List.foldr (D.map2 (::)) (D.succeed []) + |> D.map Dict.fromList + + +{-| Convenience: read a taxonomy's term ids from the post, defaulting to `[]`. -} +taxonomyOf : String -> Post -> List Int +taxonomyOf key post = + Dict.get key post.taxonomies + |> Maybe.withDefault [] + + +{-| Convenience: read a post meta value as a raw `Value`, defaulting to `null`. -} +metaOf : String -> Post -> D.Value +metaOf key post = + Dict.get key post.meta + |> Maybe.withDefault E.null + + +{-| Body for create/update. All fields optional; only set what you want to +change. `taxonomies` and `meta` default to `Dict.empty`. + +For `status`, valid values are `"publish"`, `"future"`, `"draft"`, `"pending"`, +`"private"`. WordPress will reject anything else. +-} +type alias Body = + { title : Maybe String + , content : Maybe String + , excerpt : Maybe String + , status : Maybe String + , slug : Maybe String + , author : Maybe Int + , featuredMedia : Maybe Int + , taxonomies : Dict String (List Int) + , meta : Dict String E.Value + } + + +{-| All-`Nothing` body — start here and use record-update to set fields. -} +emptyBody : Body +emptyBody = + { title = Nothing + , content = Nothing + , excerpt = Nothing + , status = Nothing + , slug = Nothing + , author = Nothing + , featuredMedia = Nothing + , taxonomies = Dict.empty + , meta = Dict.empty + } + + +{-| Encode a body into JSON suitable for POST. -} +encodeBody : Body -> E.Value +encodeBody body = + let + scalar = + List.filterMap identity + [ Maybe.map (\v -> ( "title", E.string v )) body.title + , Maybe.map (\v -> ( "content", E.string v )) body.content + , Maybe.map (\v -> ( "excerpt", E.string v )) body.excerpt + , Maybe.map (\v -> ( "status", E.string v )) body.status + , Maybe.map (\v -> ( "slug", E.string v )) body.slug + , Maybe.map (\v -> ( "author", E.int v )) body.author + , Maybe.map (\v -> ( "featured_media", E.int v )) body.featuredMedia + ] + + taxonomies = + body.taxonomies + |> Dict.toList + |> List.map (\( k, ids ) -> ( k, E.list E.int ids )) + + metaPair = + if Dict.isEmpty body.meta then + [] + + else + [ ( "meta", E.object (Dict.toList body.meta) ) ] + in + E.object (scalar ++ taxonomies ++ metaPair) + + +{-| Query params for `list`. `decodeKeys` controls what taxonomies and meta +the decoder extracts from each post in the response. `taxonomyFilters` filters +the post list by taxonomy term ids — e.g. `Dict.fromList [("categories", +[3])]` becomes `?categories=3`. +-} +type alias ListParams = + { perPage : Int + , page : Int + , search : Maybe String + , status : Maybe String + , orderby : Maybe String + , order : Maybe String + , author : Maybe Int + , decodeKeys : DecodeKeys + , taxonomyFilters : Dict String (List Int) + } + + +{-| 10 per page, page 1, no filters, no taxonomies/meta extracted. -} +defaultListParams : ListParams +defaultListParams = + { perPage = 10 + , page = 1 + , search = Nothing + , status = Nothing + , orderby = Nothing + , order = Nothing + , author = Nothing + , decodeKeys = defaultDecodeKeys + , taxonomyFilters = Dict.empty + } + + +{-| GET `/wp/v2/posts` with the given params. -} +list : Flags -> ListParams -> (Result WP.Http.Error (List Post) -> msg) -> Cmd msg +list flags params toMsg = + WP.Http.get flags (listUrl params) (D.list (decoder params.decodeKeys)) toMsg + + +{-| GET `/wp/v2/posts/{id}`. -} +get : Flags -> Int -> DecodeKeys -> (Result WP.Http.Error Post -> msg) -> Cmd msg +get flags id keys toMsg = + WP.Http.get flags ("/wp/v2/posts/" ++ String.fromInt id) (decoder keys) toMsg + + +{-| POST `/wp/v2/posts`. The keys control what's decoded out of the response. -} +create : Flags -> DecodeKeys -> Body -> (Result WP.Http.Error Post -> msg) -> Cmd msg +create flags keys body toMsg = + WP.Http.post flags "/wp/v2/posts" (encodeBody body) (decoder keys) toMsg + + +{-| POST `/wp/v2/posts/{id}` for partial update. -} +update : Flags -> Int -> DecodeKeys -> Body -> (Result WP.Http.Error Post -> msg) -> Cmd msg +update flags id keys body toMsg = + WP.Http.post flags ("/wp/v2/posts/" ++ String.fromInt id) (encodeBody body) (decoder keys) toMsg + + +{-| DELETE `/wp/v2/posts/{id}`. Returns the raw response body. -} +delete : Flags -> Int -> (Result WP.Http.Error D.Value -> msg) -> Cmd msg +delete flags id toMsg = + WP.Http.delete flags ("/wp/v2/posts/" ++ String.fromInt id) D.value toMsg + + +listUrl : ListParams -> String +listUrl params = + let + baseParams = + [ ( "per_page", Just (String.fromInt params.perPage) ) + , ( "page", Just (String.fromInt params.page) ) + , ( "search", params.search ) + , ( "status", params.status ) + , ( "orderby", params.orderby ) + , ( "order", params.order ) + , ( "author", Maybe.map String.fromInt params.author ) + ] + + taxFilters = + params.taxonomyFilters + |> Dict.toList + |> List.map (\( k, ids ) -> ( k, Just (WP.Query.intList ids) )) + in + "/wp/v2/posts" ++ WP.Query.build (baseParams ++ taxFilters) diff --git a/src/WP/Query.elm b/src/WP/Query.elm new file mode 100644 index 0000000..6d7d7c0 --- /dev/null +++ b/src/WP/Query.elm @@ -0,0 +1,41 @@ +module WP.Query exposing (Param, build, intList, fromInt) + +{-| Internal — URL query string builder shared by WP.Post / WP.Term / WP.User. + +Each entry is `(key, Maybe value)` — `Nothing` is dropped, `Just v` becomes +`?key=encoded(v)`. Both keys and values are percent-encoded via `Url.percentEncode`. +-} + +import Url + + +type alias Param = + ( String, Maybe String ) + + +build : List Param -> String +build params = + let + encoded = + List.filterMap encodePair params + in + if List.isEmpty encoded then + "" + + else + "?" ++ String.join "&" encoded + + +encodePair : Param -> Maybe String +encodePair ( key, maybeValue ) = + Maybe.map (\v -> Url.percentEncode key ++ "=" ++ Url.percentEncode v) maybeValue + + +intList : List Int -> String +intList xs = + String.join "," (List.map String.fromInt xs) + + +fromInt : Int -> String +fromInt = + String.fromInt diff --git a/src/WP/Tax.elm b/src/WP/Tax.elm new file mode 100644 index 0000000..c3703ba --- /dev/null +++ b/src/WP/Tax.elm @@ -0,0 +1,61 @@ +module WP.Tax exposing + ( Taxonomy + , decoder + , list, get + ) + +{-| Read-only access to registered taxonomies via `/wp/v2/taxonomies`. + +The REST API doesn't let you create taxonomies — they must be registered in +PHP via `register_taxonomy()`. This module just exposes the registry so Elm +apps can ask "what taxonomies apply to this post type?" or similar. + +@docs Taxonomy, decoder, list, get +-} + +import Json.Decode as D +import Json.Decode.Pipeline exposing (optional, required) +import WP.Flags exposing (Flags) +import WP.Http + + +{-| -} +type alias Taxonomy = + { name : String + , slug : String + , description : String + , hierarchical : Bool + , types : List String + , restBase : String + } + + +{-| -} +decoder : D.Decoder Taxonomy +decoder = + D.succeed Taxonomy + |> required "name" D.string + |> required "slug" D.string + |> optional "description" D.string "" + |> required "hierarchical" D.bool + |> required "types" (D.list D.string) + |> required "rest_base" D.string + + +{-| GET `/wp/v2/taxonomies`. The endpoint returns an object keyed by taxonomy +slug; this list helper flattens it to a list of `Taxonomy` records. -} +list : Flags -> (Result WP.Http.Error (List Taxonomy) -> msg) -> Cmd msg +list flags toMsg = + WP.Http.get flags "/wp/v2/taxonomies" listDecoder toMsg + + +{-| GET `/wp/v2/taxonomies/{slug}`. -} +get : Flags -> String -> (Result WP.Http.Error Taxonomy -> msg) -> Cmd msg +get flags slug toMsg = + WP.Http.get flags ("/wp/v2/taxonomies/" ++ slug) decoder toMsg + + +listDecoder : D.Decoder (List Taxonomy) +listDecoder = + D.keyValuePairs decoder + |> D.map (List.map Tuple.second) diff --git a/src/WP/Term.elm b/src/WP/Term.elm new file mode 100644 index 0000000..a510fac --- /dev/null +++ b/src/WP/Term.elm @@ -0,0 +1,230 @@ +module WP.Term exposing + ( Term + , DecodeKeys, defaultDecodeKeys + , decoder, metaOf + , Body, emptyBody, encodeBody + , ListParams, defaultListParams + , list, get, create, update, delete + ) + +{-| Typed read/write of taxonomy terms (`/wp/v2/categories`, `/wp/v2/tags`, +`/wp/v2/`). + +All operations take a `restBase` argument identifying the taxonomy's REST +slug — `"categories"`, `"tags"`, or whatever the custom taxonomy registered. +That's the `rest_base` field from `WP.Tax`, **not** the taxonomy slug. + +Term meta registered via `register_term_meta(..., 'show_in_rest' => true)` +is exposed via the `meta` Dict — pass the keys you want extracted via +`DecodeKeys`. See the `WP.Post` module docs for the same pattern. + +@docs Term +@docs DecodeKeys, defaultDecodeKeys +@docs decoder, metaOf +@docs Body, emptyBody, encodeBody +@docs ListParams, defaultListParams +@docs list, get, create, update, delete +-} + +import Dict exposing (Dict) +import Json.Decode as D +import Json.Decode.Pipeline exposing (custom, optional, required) +import Json.Encode as E +import WP.Flags exposing (Flags) +import WP.Http +import WP.Query + + +{-| -} +type alias Term = + { id : Int + , count : Int + , description : String + , link : String + , name : String + , slug : String + , taxonomy : String + , parent : Int + , meta : Dict String D.Value + } + + +{-| Tells the decoder which registered term-meta keys to extract from the +response. +-} +type alias DecodeKeys = + { meta : List String } + + +{-| Empty `DecodeKeys` — no meta extracted. -} +defaultDecodeKeys : DecodeKeys +defaultDecodeKeys = + { meta = [] } + + +{-| -} +decoder : DecodeKeys -> D.Decoder Term +decoder keys = + D.succeed Term + |> required "id" D.int + |> optional "count" D.int 0 + |> optional "description" D.string "" + |> required "link" D.string + |> required "name" D.string + |> required "slug" D.string + |> required "taxonomy" D.string + |> optional "parent" D.int 0 + |> custom (metaDecoder keys.meta) + + +metaDecoder : List String -> D.Decoder (Dict String D.Value) +metaDecoder keys = + let + readKey key = + D.maybe (D.at [ "meta", key ] D.value) + |> D.map (\m -> ( key, Maybe.withDefault E.null m )) + in + keys + |> List.map readKey + |> List.foldr (D.map2 (::)) (D.succeed []) + |> D.map Dict.fromList + + +{-| Convenience: read a term meta value as a raw `Value`, defaulting to `null`. -} +metaOf : String -> Term -> D.Value +metaOf key term = + Dict.get key term.meta + |> Maybe.withDefault E.null + + +{-| Body for create/update. -} +type alias Body = + { name : Maybe String + , description : Maybe String + , slug : Maybe String + , parent : Maybe Int + , meta : Dict String E.Value + } + + +{-| -} +emptyBody : Body +emptyBody = + { name = Nothing + , description = Nothing + , slug = Nothing + , parent = Nothing + , meta = Dict.empty + } + + +{-| -} +encodeBody : Body -> E.Value +encodeBody body = + let + scalar = + List.filterMap identity + [ Maybe.map (\v -> ( "name", E.string v )) body.name + , Maybe.map (\v -> ( "description", E.string v )) body.description + , Maybe.map (\v -> ( "slug", E.string v )) body.slug + , Maybe.map (\v -> ( "parent", E.int v )) body.parent + ] + + metaPair = + if Dict.isEmpty body.meta then + [] + + else + [ ( "meta", E.object (Dict.toList body.meta) ) ] + in + E.object (scalar ++ metaPair) + + +{-| -} +type alias ListParams = + { perPage : Int + , page : Int + , search : Maybe String + , orderby : Maybe String + , order : Maybe String + , parent : Maybe Int + , post : Maybe Int + , hideEmpty : Maybe Bool + , decodeKeys : DecodeKeys + } + + +{-| -} +defaultListParams : ListParams +defaultListParams = + { perPage = 10 + , page = 1 + , search = Nothing + , orderby = Nothing + , order = Nothing + , parent = Nothing + , post = Nothing + , hideEmpty = Nothing + , decodeKeys = defaultDecodeKeys + } + + +{-| GET `/wp/v2/{restBase}` with the given params. -} +list : Flags -> String -> ListParams -> (Result WP.Http.Error (List Term) -> msg) -> Cmd msg +list flags restBase params toMsg = + WP.Http.get flags (listUrl restBase params) (D.list (decoder params.decodeKeys)) toMsg + + +{-| GET `/wp/v2/{restBase}/{id}`. -} +get : Flags -> String -> Int -> DecodeKeys -> (Result WP.Http.Error Term -> msg) -> Cmd msg +get flags restBase id keys toMsg = + WP.Http.get flags (basePath restBase ++ "/" ++ String.fromInt id) (decoder keys) toMsg + + +{-| POST `/wp/v2/{restBase}`. -} +create : Flags -> String -> DecodeKeys -> Body -> (Result WP.Http.Error Term -> msg) -> Cmd msg +create flags restBase keys body toMsg = + WP.Http.post flags (basePath restBase) (encodeBody body) (decoder keys) toMsg + + +{-| POST `/wp/v2/{restBase}/{id}`. -} +update : Flags -> String -> Int -> DecodeKeys -> Body -> (Result WP.Http.Error Term -> msg) -> Cmd msg +update flags restBase id keys body toMsg = + WP.Http.post flags (basePath restBase ++ "/" ++ String.fromInt id) (encodeBody body) (decoder keys) toMsg + + +{-| DELETE `/wp/v2/{restBase}/{id}?force=true`. WordPress requires `force=true` +for term deletion (terms can't go to a trash). -} +delete : Flags -> String -> Int -> (Result WP.Http.Error D.Value -> msg) -> Cmd msg +delete flags restBase id toMsg = + WP.Http.delete flags (basePath restBase ++ "/" ++ String.fromInt id ++ "?force=true") D.value toMsg + + +basePath : String -> String +basePath restBase = + "/wp/v2/" ++ restBase + + +listUrl : String -> ListParams -> String +listUrl restBase params = + basePath restBase + ++ WP.Query.build + [ ( "per_page", Just (String.fromInt params.perPage) ) + , ( "page", Just (String.fromInt params.page) ) + , ( "search", params.search ) + , ( "orderby", params.orderby ) + , ( "order", params.order ) + , ( "parent", Maybe.map String.fromInt params.parent ) + , ( "post", Maybe.map String.fromInt params.post ) + , ( "hide_empty" + , Maybe.map + (\b -> + if b then + "true" + + else + "false" + ) + params.hideEmpty + ) + ] diff --git a/src/WP/User.elm b/src/WP/User.elm index 3c8ae5f..243ac45 100644 --- a/src/WP/User.elm +++ b/src/WP/User.elm @@ -1,19 +1,49 @@ -module WP.User exposing (User, decoder, can, hasRole) +module WP.User exposing + ( User + , decoder + , can, hasRole + , DecodeKeys, defaultDecodeKeys + , restDecoder, metaOf + , Body, emptyBody, encodeBody + , ListParams, defaultListParams + , list, get, create, update, delete + ) -{-| The current WordPress user, as emitted by `pinkcrab/elm-mount`. +{-| WordPress users — both the current user (via flags) and the +`/wp/v2/users` REST collection. -`currentUser` on the flags blob is `null` when the visitor is logged out; use -`Maybe User` in your app's model rather than a bare `User`. +The same `User` record covers both. The flags-side blob populates `roles` +and `capabilities` because we read them from PHP's `WP_User` directly. The +REST endpoint omits those fields under the default `view` context — they +only appear with `?context=edit`. Either way, the decoder fills missing +fields with empty lists rather than failing, so a flags-side user and a +REST-side user end up the same shape. -Capabilities and roles are exposed for UI hints only — **never rely on them for -authorisation**. Server-side permission callbacks (REST or admin) are the real -gate; this client-side snapshot is tamperable by anyone with devtools. +User meta registered via `register_user_meta(..., 'show_in_rest' => true)` +is exposed via the `meta` Dict — pass the keys you want extracted via +`DecodeKeys`. Flags-side currentUser always has `meta = Dict.empty` since +PHP-side `Standard_Flags` doesn't currently emit user meta. -@docs User, decoder, can, hasRole +Capabilities and roles are UI hints only — **never rely on them for +authorisation**. Server-side permission callbacks (REST or admin) are the +real gate; this client-side snapshot is tamperable. + +@docs User +@docs decoder, can, hasRole +@docs DecodeKeys, defaultDecodeKeys +@docs restDecoder, metaOf +@docs Body, emptyBody, encodeBody +@docs ListParams, defaultListParams +@docs list, get, create, update, delete -} +import Dict exposing (Dict) import Json.Decode as D -import Json.Decode.Pipeline exposing (required) +import Json.Decode.Pipeline exposing (custom, optional, required) +import Json.Encode as E +import WP.Flags exposing (Flags) +import WP.Http +import WP.Query {-| -} @@ -22,10 +52,17 @@ type alias User = , displayName : String , roles : List String , capabilities : List String + , meta : Dict String D.Value } -{-| Decodes the `currentUser` object from the flags blob. -} +{-| Decoder for the `currentUser` object on the flags blob (PHP-side shape). + +The same record shape lives in `WP.User.Type` (an internal module that +exists to break the WP.User → WP.Http → WP.Flags → WP.User import cycle). +The two definitions are structurally identical — Elm record aliases unify, +so `flags.currentUser` and `WP.User.User` are interchangeable. +-} decoder : D.Decoder User decoder = D.succeed User @@ -33,6 +70,60 @@ decoder = |> required "displayName" D.string |> required "roles" (D.list D.string) |> required "capabilities" (D.list D.string) + |> optional "meta" (D.dict D.value) Dict.empty + + +{-| Tells the decoder which registered user-meta keys to extract from the +response. +-} +type alias DecodeKeys = + { meta : List String } + + +{-| Empty `DecodeKeys` — no meta extracted. -} +defaultDecodeKeys : DecodeKeys +defaultDecodeKeys = + { meta = [] } + + +{-| Decoder for `/wp/v2/users` responses. Maps `name` → `displayName`, +fills `roles` / `capabilities` with empty lists when the response context +doesn't include them, and extracts the named meta keys. +-} +restDecoder : DecodeKeys -> D.Decoder User +restDecoder keys = + D.succeed User + |> required "id" D.int + |> required "name" D.string + |> optional "roles" (D.list D.string) [] + |> optional "capabilities" capabilitiesObjectDecoder [] + |> custom (metaDecoder keys.meta) + + +capabilitiesObjectDecoder : D.Decoder (List String) +capabilitiesObjectDecoder = + D.keyValuePairs D.bool + |> D.map (List.filter Tuple.second >> List.map Tuple.first) + + +metaDecoder : List String -> D.Decoder (Dict String D.Value) +metaDecoder keys = + let + readKey key = + D.maybe (D.at [ "meta", key ] D.value) + |> D.map (\m -> ( key, Maybe.withDefault E.null m )) + in + keys + |> List.map readKey + |> List.foldr (D.map2 (::)) (D.succeed []) + |> D.map Dict.fromList + + +{-| Convenience: read a user meta value as a raw `Value`, defaulting to `null`. -} +metaOf : String -> User -> D.Value +metaOf key user = + Dict.get key user.meta + |> Maybe.withDefault E.null {-| True if the user has the named capability. UI hint only. -} @@ -45,3 +136,154 @@ can capability user = hasRole : String -> User -> Bool hasRole role user = List.member role user.roles + + +{-| Body for create/update. + +`username`, `email`, `password` are required when creating but optional when +updating (you can update e.g. just the `name`). +-} +type alias Body = + { username : Maybe String + , email : Maybe String + , password : Maybe String + , name : Maybe String + , firstName : Maybe String + , lastName : Maybe String + , slug : Maybe String + , url : Maybe String + , description : Maybe String + , locale : Maybe String + , roles : Maybe (List String) + , meta : Dict String E.Value + } + + +{-| All-`Nothing` body — start here and use record-update. -} +emptyBody : Body +emptyBody = + { username = Nothing + , email = Nothing + , password = Nothing + , name = Nothing + , firstName = Nothing + , lastName = Nothing + , slug = Nothing + , url = Nothing + , description = Nothing + , locale = Nothing + , roles = Nothing + , meta = Dict.empty + } + + +{-| -} +encodeBody : Body -> E.Value +encodeBody body = + let + scalar = + List.filterMap identity + [ Maybe.map (\v -> ( "username", E.string v )) body.username + , Maybe.map (\v -> ( "email", E.string v )) body.email + , Maybe.map (\v -> ( "password", E.string v )) body.password + , Maybe.map (\v -> ( "name", E.string v )) body.name + , Maybe.map (\v -> ( "first_name", E.string v )) body.firstName + , Maybe.map (\v -> ( "last_name", E.string v )) body.lastName + , Maybe.map (\v -> ( "slug", E.string v )) body.slug + , Maybe.map (\v -> ( "url", E.string v )) body.url + , Maybe.map (\v -> ( "description", E.string v )) body.description + , Maybe.map (\v -> ( "locale", E.string v )) body.locale + , Maybe.map (\v -> ( "roles", E.list E.string v )) body.roles + ] + + metaPair = + if Dict.isEmpty body.meta then + [] + + else + [ ( "meta", E.object (Dict.toList body.meta) ) ] + in + E.object (scalar ++ metaPair) + + +{-| -} +type alias ListParams = + { perPage : Int + , page : Int + , search : Maybe String + , orderby : Maybe String + , order : Maybe String + , roles : Maybe (List String) + , who : Maybe String + , decodeKeys : DecodeKeys + } + + +{-| -} +defaultListParams : ListParams +defaultListParams = + { perPage = 10 + , page = 1 + , search = Nothing + , orderby = Nothing + , order = Nothing + , roles = Nothing + , who = Nothing + , decodeKeys = defaultDecodeKeys + } + + +{-| GET `/wp/v2/users`. -} +list : Flags -> ListParams -> (Result WP.Http.Error (List User) -> msg) -> Cmd msg +list flags params toMsg = + WP.Http.get flags (listUrl params) (D.list (restDecoder params.decodeKeys)) toMsg + + +{-| GET `/wp/v2/users/{id}`. -} +get : Flags -> Int -> DecodeKeys -> (Result WP.Http.Error User -> msg) -> Cmd msg +get flags id keys toMsg = + WP.Http.get flags ("/wp/v2/users/" ++ String.fromInt id) (restDecoder keys) toMsg + + +{-| POST `/wp/v2/users`. -} +create : Flags -> DecodeKeys -> Body -> (Result WP.Http.Error User -> msg) -> Cmd msg +create flags keys body toMsg = + WP.Http.post flags "/wp/v2/users" (encodeBody body) (restDecoder keys) toMsg + + +{-| POST `/wp/v2/users/{id}` for partial update. -} +update : Flags -> Int -> DecodeKeys -> Body -> (Result WP.Http.Error User -> msg) -> Cmd msg +update flags id keys body toMsg = + WP.Http.post flags ("/wp/v2/users/" ++ String.fromInt id) (encodeBody body) (restDecoder keys) toMsg + + +{-| DELETE `/wp/v2/users/{id}?force=true&reassign={reassignId}`. + +WordPress requires `force=true` (users don't go to trash) and `reassign` +(an int user id to receive any posts authored by the deleted user — pass +the deleted user's own id back to wipe their posts, or another user's id +to reassign). -} +delete : Flags -> Int -> Int -> (Result WP.Http.Error D.Value -> msg) -> Cmd msg +delete flags id reassignId toMsg = + WP.Http.delete flags + ("/wp/v2/users/" + ++ String.fromInt id + ++ "?force=true&reassign=" + ++ String.fromInt reassignId + ) + D.value + toMsg + + +listUrl : ListParams -> String +listUrl params = + "/wp/v2/users" + ++ WP.Query.build + [ ( "per_page", Just (String.fromInt params.perPage) ) + , ( "page", Just (String.fromInt params.page) ) + , ( "search", params.search ) + , ( "orderby", params.orderby ) + , ( "order", params.order ) + , ( "roles", Maybe.map (String.join ",") params.roles ) + , ( "who", params.who ) + ] diff --git a/src/WP/User/Type.elm b/src/WP/User/Type.elm new file mode 100644 index 0000000..6c210ef --- /dev/null +++ b/src/WP/User/Type.elm @@ -0,0 +1,43 @@ +module WP.User.Type exposing (User, decoder) + +{-| Internal — the `User` record + flags-side decoder shared between +`WP.User` and `WP.Flags`. + +Lives in its own module to break the import cycle that would otherwise +exist (WP.User → WP.Http → WP.Flags → WP.User). Consumers should +`import WP.User exposing (User)` rather than importing this module +directly. + +`meta` defaults to an empty `Dict` for flags-side users — `pinkcrab/elm-mount`'s +`Standard_Flags` doesn't currently emit user meta. REST responses populate +`meta` based on the `DecodeKeys` you pass to `WP.User.list`/`get`/`restDecoder`. + +@docs User, decoder +-} + +import Dict exposing (Dict) +import Json.Decode as D +import Json.Decode.Pipeline exposing (optional, required) + + +{-| -} +type alias User = + { id : Int + , displayName : String + , roles : List String + , capabilities : List String + , meta : Dict String D.Value + } + + +{-| Decoder for the `currentUser` object on the flags blob (PHP-side shape). +`meta` is filled with `Dict.empty` since flags-side currentUser doesn't +include user meta. -} +decoder : D.Decoder User +decoder = + D.succeed User + |> required "id" D.int + |> required "displayName" D.string + |> required "roles" (D.list D.string) + |> required "capabilities" (D.list D.string) + |> optional "meta" (D.dict D.value) Dict.empty diff --git a/tests/PostTest.elm b/tests/PostTest.elm new file mode 100644 index 0000000..6bee8c2 --- /dev/null +++ b/tests/PostTest.elm @@ -0,0 +1,209 @@ +module PostTest exposing (suite) + +import Dict +import Expect +import Json.Decode as D +import Json.Encode as E +import Test exposing (Test, describe, test) +import WP.Post + + +suite : Test +suite = + describe "WP.Post" + [ describe "decoder" + [ test "extracts rendered values for title/content/excerpt" <| + \_ -> + case D.decodeValue (WP.Post.decoder WP.Post.defaultDecodeKeys) samplePostJson of + Ok post -> + Expect.all + [ \p -> Expect.equal "Hello world" p.title + , \p -> Expect.equal "

Body content.

" p.content + , \p -> Expect.equal "

Short.

" p.excerpt + ] + post + + Err err -> + Expect.fail (D.errorToString err) + , test "fills missing taxonomies with empty list" <| + \_ -> + let + keys = + { taxonomies = [ "categories", "tags", "genre" ], meta = [] } + in + case D.decodeValue (WP.Post.decoder keys) samplePostJson of + Ok post -> + Expect.equal + (Dict.fromList + [ ( "categories", [ 1, 2 ] ) + , ( "tags", [ 5 ] ) + , ( "genre", [] ) + ] + ) + post.taxonomies + + Err err -> + Expect.fail (D.errorToString err) + , test "ignores taxonomies not in keys list" <| + \_ -> + case D.decodeValue (WP.Post.decoder WP.Post.defaultDecodeKeys) samplePostJson of + Ok post -> + Expect.equal Dict.empty post.taxonomies + + Err err -> + Expect.fail (D.errorToString err) + , test "extracts requested meta keys" <| + \_ -> + let + keys = + { taxonomies = [], meta = [ "_difficulty", "_servings" ] } + in + case D.decodeValue (WP.Post.decoder keys) samplePostJson of + Ok post -> + Expect.all + [ \p -> + Expect.equal + (Just "Easy") + (Dict.get "_difficulty" p.meta + |> Maybe.andThen + (\v -> + D.decodeValue D.string v |> Result.toMaybe + ) + ) + , \p -> + Expect.equal + (Just 4) + (Dict.get "_servings" p.meta + |> Maybe.andThen + (\v -> + D.decodeValue D.int v |> Result.toMaybe + ) + ) + ] + post + + Err err -> + Expect.fail (D.errorToString err) + , test "missing meta keys default to null" <| + \_ -> + let + keys = + { taxonomies = [], meta = [ "_unset_key" ] } + in + case D.decodeValue (WP.Post.decoder keys) samplePostJson of + Ok post -> + Expect.equal + (Just E.null |> Maybe.map (E.encode 0)) + (Dict.get "_unset_key" post.meta |> Maybe.map (E.encode 0)) + + Err err -> + Expect.fail (D.errorToString err) + ] + , describe "taxonomyOf" + [ test "returns the list when present" <| + \_ -> + let + post = + stubPost (Dict.fromList [ ( "categories", [ 7, 9 ] ) ]) Dict.empty + in + Expect.equal [ 7, 9 ] (WP.Post.taxonomyOf "categories" post) + , test "returns empty list when missing" <| + \_ -> + let + post = + stubPost Dict.empty Dict.empty + in + Expect.equal [] (WP.Post.taxonomyOf "missing" post) + ] + , describe "metaOf" + [ test "returns null Value when missing" <| + \_ -> + let + post = + stubPost Dict.empty Dict.empty + in + Expect.equal (E.encode 0 E.null) (E.encode 0 (WP.Post.metaOf "absent" post)) + ] + , describe "encodeBody" + [ test "drops Nothing fields" <| + \_ -> + let + body = + { emptyBody | title = Just "Hello" } + + expected = + E.object [ ( "title", E.string "Hello" ) ] + in + Expect.equal (E.encode 0 expected) (E.encode 0 (WP.Post.encodeBody body)) + , test "renames featuredMedia to featured_media" <| + \_ -> + let + body = + { emptyBody | featuredMedia = Just 42 } + in + Expect.equal True + (String.contains "\"featured_media\":42" (E.encode 0 (WP.Post.encodeBody body))) + , test "encodes taxonomies dict as top-level keys" <| + \_ -> + let + body = + { emptyBody | taxonomies = Dict.fromList [ ( "genre", [ 1, 2 ] ) ] } + in + Expect.equal True + (String.contains "\"genre\":[1,2]" (E.encode 0 (WP.Post.encodeBody body))) + , test "wraps meta dict under a meta key" <| + \_ -> + let + body = + { emptyBody | meta = Dict.fromList [ ( "_difficulty", E.string "Easy" ) ] } + in + Expect.equal True + (String.contains "\"meta\":{\"_difficulty\":\"Easy\"}" (E.encode 0 (WP.Post.encodeBody body))) + ] + ] + + +emptyBody : WP.Post.Body +emptyBody = + WP.Post.emptyBody + + +stubPost : Dict.Dict String (List Int) -> Dict.Dict String D.Value -> WP.Post.Post +stubPost taxonomies meta = + { id = 1 + , date = "2026-01-01T00:00:00" + , slug = "x" + , status = "publish" + , link = "https://example/x" + , title = "x" + , content = "" + , excerpt = "" + , author = 1 + , featuredMedia = 0 + , taxonomies = taxonomies + , meta = meta + } + + +samplePostJson : E.Value +samplePostJson = + E.object + [ ( "id", E.int 7 ) + , ( "date", E.string "2026-01-01T00:00:00" ) + , ( "slug", E.string "hello-world" ) + , ( "status", E.string "publish" ) + , ( "link", E.string "https://example.test/hello-world" ) + , ( "title", E.object [ ( "rendered", E.string "Hello world" ) ] ) + , ( "content", E.object [ ( "rendered", E.string "

Body content.

" ) ] ) + , ( "excerpt", E.object [ ( "rendered", E.string "

Short.

" ) ] ) + , ( "author", E.int 1 ) + , ( "featured_media", E.int 0 ) + , ( "categories", E.list E.int [ 1, 2 ] ) + , ( "tags", E.list E.int [ 5 ] ) + , ( "meta" + , E.object + [ ( "_difficulty", E.string "Easy" ) + , ( "_servings", E.int 4 ) + ] + ) + ] diff --git a/tests/QueryTest.elm b/tests/QueryTest.elm new file mode 100644 index 0000000..bf8bf3e --- /dev/null +++ b/tests/QueryTest.elm @@ -0,0 +1,48 @@ +module QueryTest exposing (suite) + +import Expect +import Test exposing (Test, describe, test) +import WP.Query + + +suite : Test +suite = + describe "WP.Query" + [ describe "build" + [ test "empty params produces an empty string" <| + \_ -> + Expect.equal "" (WP.Query.build []) + , test "Nothing params are dropped" <| + \_ -> + Expect.equal "" + (WP.Query.build + [ ( "search", Nothing ) + , ( "status", Nothing ) + ] + ) + , test "single Just param" <| + \_ -> + Expect.equal "?per_page=10" + (WP.Query.build [ ( "per_page", Just "10" ) ]) + , test "multiple params joined with &" <| + \_ -> + Expect.equal "?per_page=10&page=2" + (WP.Query.build + [ ( "per_page", Just "10" ) + , ( "page", Just "2" ) + ] + ) + , test "values are percent-encoded" <| + \_ -> + Expect.equal "?search=hello%20world" + (WP.Query.build [ ( "search", Just "hello world" ) ]) + ] + , describe "intList" + [ test "joins ints with commas" <| + \_ -> + Expect.equal "1,2,3" (WP.Query.intList [ 1, 2, 3 ]) + , test "empty list yields empty string" <| + \_ -> + Expect.equal "" (WP.Query.intList []) + ] + ] diff --git a/tests/TaxTest.elm b/tests/TaxTest.elm new file mode 100644 index 0000000..759b577 --- /dev/null +++ b/tests/TaxTest.elm @@ -0,0 +1,61 @@ +module TaxTest exposing (suite) + +import Expect +import Json.Decode as D +import Json.Encode as E +import Test exposing (Test, describe, test) +import WP.Tax + + +suite : Test +suite = + describe "WP.Tax" + [ describe "decoder" + [ test "decodes a single taxonomy object" <| + \_ -> + case D.decodeValue WP.Tax.decoder categoryTaxJson of + Ok tax -> + Expect.all + [ \t -> Expect.equal "Categories" t.name + , \t -> Expect.equal "category" t.slug + , \t -> Expect.equal True t.hierarchical + , \t -> Expect.equal "categories" t.restBase + , \t -> Expect.equal [ "post" ] t.types + ] + tax + + Err err -> + Expect.fail (D.errorToString err) + , test "fills missing description with empty string" <| + \_ -> + case D.decodeValue WP.Tax.decoder minimalTaxJson of + Ok tax -> + Expect.equal "" tax.description + + Err err -> + Expect.fail (D.errorToString err) + ] + ] + + +categoryTaxJson : E.Value +categoryTaxJson = + E.object + [ ( "name", E.string "Categories" ) + , ( "slug", E.string "category" ) + , ( "description", E.string "Default WP categories" ) + , ( "hierarchical", E.bool True ) + , ( "types", E.list E.string [ "post" ] ) + , ( "rest_base", E.string "categories" ) + ] + + +minimalTaxJson : E.Value +minimalTaxJson = + E.object + [ ( "name", E.string "Genres" ) + , ( "slug", E.string "genre" ) + , ( "hierarchical", E.bool False ) + , ( "types", E.list E.string [ "recipe" ] ) + , ( "rest_base", E.string "genres" ) + ] diff --git a/tests/TermTest.elm b/tests/TermTest.elm new file mode 100644 index 0000000..5bcb565 --- /dev/null +++ b/tests/TermTest.elm @@ -0,0 +1,149 @@ +module TermTest exposing (suite) + +import Dict +import Expect +import Json.Decode as D +import Json.Encode as E +import Test exposing (Test, describe, test) +import WP.Term + + +suite : Test +suite = + describe "WP.Term" + [ describe "decoder" + [ test "decodes a category response" <| + \_ -> + case D.decodeValue (WP.Term.decoder WP.Term.defaultDecodeKeys) categoryJson of + Ok term -> + Expect.all + [ \t -> Expect.equal 3 t.id + , \t -> Expect.equal "Category Three" t.name + , \t -> Expect.equal "category" t.taxonomy + , \t -> Expect.equal 12 t.count + , \t -> Expect.equal 0 t.parent + ] + term + + Err err -> + Expect.fail (D.errorToString err) + , test "decodes a tag response (no parent field)" <| + \_ -> + case D.decodeValue (WP.Term.decoder WP.Term.defaultDecodeKeys) tagJson of + Ok term -> + Expect.all + [ \t -> Expect.equal 5 t.id + , \t -> Expect.equal "post_tag" t.taxonomy + , \t -> Expect.equal 0 t.parent + ] + term + + Err err -> + Expect.fail (D.errorToString err) + , test "fills missing description with empty string" <| + \_ -> + case D.decodeValue (WP.Term.decoder WP.Term.defaultDecodeKeys) tagJson of + Ok term -> + Expect.equal "" term.description + + Err err -> + Expect.fail (D.errorToString err) + , test "extracts requested term-meta keys" <| + \_ -> + let + keys = + { meta = [ "_color" ] } + in + case D.decodeValue (WP.Term.decoder keys) categoryWithMetaJson of + Ok term -> + Expect.equal + (Just "blue") + (Dict.get "_color" term.meta + |> Maybe.andThen (\v -> D.decodeValue D.string v |> Result.toMaybe) + ) + + Err err -> + Expect.fail (D.errorToString err) + ] + , describe "encodeBody" + [ test "drops Nothing fields" <| + \_ -> + let + body = + { name = Just "New tag" + , description = Nothing + , slug = Nothing + , parent = Nothing + , meta = Dict.empty + } + in + Expect.equal "{\"name\":\"New tag\"}" (E.encode 0 (WP.Term.encodeBody body)) + , test "encodes parent as int" <| + \_ -> + let + body = + { name = Just "Child" + , description = Nothing + , slug = Nothing + , parent = Just 42 + , meta = Dict.empty + } + in + Expect.equal True + (String.contains "\"parent\":42" (E.encode 0 (WP.Term.encodeBody body))) + , test "wraps meta dict under a meta key" <| + \_ -> + let + body = + { name = Just "Genre" + , description = Nothing + , slug = Nothing + , parent = Nothing + , meta = Dict.fromList [ ( "_color", E.string "blue" ) ] + } + in + Expect.equal True + (String.contains "\"meta\":{\"_color\":\"blue\"}" (E.encode 0 (WP.Term.encodeBody body))) + ] + ] + + +categoryJson : E.Value +categoryJson = + E.object + [ ( "id", E.int 3 ) + , ( "count", E.int 12 ) + , ( "description", E.string "Things in cat 3" ) + , ( "link", E.string "https://example.test/category/c3" ) + , ( "name", E.string "Category Three" ) + , ( "slug", E.string "c3" ) + , ( "taxonomy", E.string "category" ) + , ( "parent", E.int 0 ) + ] + + +tagJson : E.Value +tagJson = + E.object + [ ( "id", E.int 5 ) + , ( "count", E.int 4 ) + , ( "link", E.string "https://example.test/tag/t5" ) + , ( "name", E.string "Tag Five" ) + , ( "slug", E.string "t5" ) + , ( "taxonomy", E.string "post_tag" ) + ] + + +categoryWithMetaJson : E.Value +categoryWithMetaJson = + E.object + [ ( "id", E.int 4 ) + , ( "count", E.int 1 ) + , ( "description", E.string "" ) + , ( "link", E.string "https://example.test/category/c4" ) + , ( "name", E.string "C Four" ) + , ( "slug", E.string "c4" ) + , ( "taxonomy", E.string "category" ) + , ( "parent", E.int 0 ) + , ( "meta", E.object [ ( "_color", E.string "blue" ) ] ) + ] diff --git a/tests/UserTest.elm b/tests/UserTest.elm index 061add8..8a13e5e 100644 --- a/tests/UserTest.elm +++ b/tests/UserTest.elm @@ -1,5 +1,6 @@ module UserTest exposing (suite) +import Dict import Expect import Json.Decode as D import Json.Encode as E @@ -10,7 +11,7 @@ import WP.User as User exposing (User) suite : Test suite = describe "WP.User" - [ describe "decoder" + [ describe "decoder (flags-side)" [ test "decodes a complete user object" <| \_ -> case D.decodeValue User.decoder sampleJson of @@ -41,6 +42,67 @@ suite = Err _ -> Expect.pass + , test "defaults meta to empty dict when not present" <| + \_ -> + case D.decodeValue User.decoder sampleJson of + Ok user -> + Expect.equal Dict.empty user.meta + + Err err -> + Expect.fail (D.errorToString err) + ] + , describe "restDecoder" + [ test "maps name -> displayName" <| + \_ -> + case D.decodeValue (User.restDecoder User.defaultDecodeKeys) restDefaultJson of + Ok user -> + Expect.equal "Test User" user.displayName + + Err err -> + Expect.fail (D.errorToString err) + , test "fills missing roles/capabilities with empty lists (default context)" <| + \_ -> + case D.decodeValue (User.restDecoder User.defaultDecodeKeys) restDefaultJson of + Ok user -> + Expect.all + [ \u -> Expect.equal [] u.roles + , \u -> Expect.equal [] u.capabilities + ] + user + + Err err -> + Expect.fail (D.errorToString err) + , test "extracts truthy capability keys from the REST capabilities object (edit context)" <| + \_ -> + case D.decodeValue (User.restDecoder User.defaultDecodeKeys) restEditJson of + Ok user -> + Expect.all + [ \u -> Expect.equal [ "administrator" ] u.roles + , \u -> + Expect.equal + (List.sort [ "edit_posts", "manage_options" ]) + (List.sort u.capabilities) + ] + user + + Err err -> + Expect.fail (D.errorToString err) + , test "extracts requested user-meta keys" <| + \_ -> + let + keys = + { meta = [ "_my_pref" ] } + in + case D.decodeValue (User.restDecoder keys) restWithMetaJson of + Ok user -> + Expect.equal + (Just "dark") + (Dict.get "_my_pref" user.meta + |> Maybe.andThen (\v -> D.decodeValue D.string v |> Result.toMaybe) + ) + + Err err -> + Expect.fail (D.errorToString err) ] , describe "can" [ test "true when capability is present" <| @@ -58,15 +120,54 @@ suite = \_ -> Expect.equal False (User.hasRole "administrator" sampleUser) ] + , describe "encodeBody" + [ test "drops Nothing fields" <| + \_ -> + let + body = + { emptyBody | username = Just "alice", email = Just "a@b.com" } + in + Expect.equal + "{\"username\":\"alice\",\"email\":\"a@b.com\"}" + (E.encode 0 (User.encodeBody body)) + , test "renames firstName/lastName to first_name/last_name" <| + \_ -> + let + body = + { emptyBody | firstName = Just "Ada", lastName = Just "Lovelace" } + + json = + E.encode 0 (User.encodeBody body) + in + Expect.all + [ \s -> Expect.equal True (String.contains "\"first_name\":\"Ada\"" s) + , \s -> Expect.equal True (String.contains "\"last_name\":\"Lovelace\"" s) + ] + json + , test "wraps meta dict under a meta key" <| + \_ -> + let + body = + { emptyBody | meta = Dict.fromList [ ( "_my_pref", E.string "dark" ) ] } + in + Expect.equal True + (String.contains "\"meta\":{\"_my_pref\":\"dark\"}" (E.encode 0 (User.encodeBody body))) + ] ] +emptyBody : User.Body +emptyBody = + User.emptyBody + + sampleUser : User sampleUser = { id = 7 , displayName = "Test User" , roles = [ "editor" ] , capabilities = [ "edit_posts", "publish_posts" ] + , meta = Dict.empty } @@ -78,3 +179,42 @@ sampleJson = , ( "roles", E.list E.string [ "editor" ] ) , ( "capabilities", E.list E.string [ "edit_posts", "publish_posts" ] ) ] + + +restDefaultJson : E.Value +restDefaultJson = + E.object + [ ( "id", E.int 7 ) + , ( "name", E.string "Test User" ) + , ( "url", E.string "" ) + , ( "description", E.string "" ) + , ( "link", E.string "https://example.test/author/test-user" ) + , ( "slug", E.string "test-user" ) + ] + + +restEditJson : E.Value +restEditJson = + E.object + [ ( "id", E.int 1 ) + , ( "name", E.string "Admin" ) + , ( "username", E.string "admin" ) + , ( "email", E.string "admin@example.test" ) + , ( "roles", E.list E.string [ "administrator" ] ) + , ( "capabilities" + , E.object + [ ( "edit_posts", E.bool True ) + , ( "manage_options", E.bool True ) + , ( "an_unused_cap", E.bool False ) + ] + ) + ] + + +restWithMetaJson : E.Value +restWithMetaJson = + E.object + [ ( "id", E.int 1 ) + , ( "name", E.string "Admin" ) + , ( "meta", E.object [ ( "_my_pref", E.string "dark" ) ] ) + ]