This proposes micropub-clj (dk.simongray/micropub-clj, root namespace
dk.simongray.micropub), a Clojure and ClojureScript implementation of
Micropub, the W3C standard with which
apps post to a website. Like webmention-clj and indieauth-clj, it would
come out of indieblog. It would take websub-clj's shape: requests as plain
maps, Ring handlers that take your own functions, nothing running in the
background, safe defaults and the spec's section beside each rule. It would
also be one cljc codebase for Clojure 1.11+ and Java 11+, with the same
results in Node and the browser.
The functions below don't exist yet, so none of the examples has run.
The code would follow the Recommendation and cite its sections. Where the living standard differs, the default follows the Recommendation (test 804 accepts 401 alone) and an option gives 403.
In the library:
me may post.Extensions. Most need no code: your functions get every property and every mp- command, and every query that the handler doesn't answer itself. So q=category, post-types, visibility, mp-slug, mp-syndicate-to, mp-destination and mp-channel work as plain data. The README would show the common ones. Two need the library:
Left out:
dk.simongray.micropub: entries and actions as data, and the
client.dk.simongray.micropub.server: the two handlers and the functions
they're made of.dk.simongray.micropub.impl.* (all ^:no-doc): form fields, Ring
bodies, links and responses.The dependencies are wary-fetch 0.4.0 for the client's requests and JSON, html-pieces 0.5.0 for the links of a page, whatwg-url 0.1.0 for URLs and indieauth-clj for tokens. The library needn't depend on microformats-clj. An entry has the shape of its output, so you can post an h-event that it parsed as it is.
An entry is mf2 JSON as microformats-clj returns it, with keyword property
names and a vector of values. A value is a string, a map such as an
{:html …} content or a photo with :alt, or a nested entry:
{:type ["h-entry"]
:properties {:content ["Hello"]
:category ["indieweb" "clojure"]
:photo [{:value "https://simon.grays.blog/assets/cat.jpg"
:alt "A cat"}]}}
An action is a Micropub request in that shape, whatever its syntax. Form
fields lose their [] and become vectors. The h field becomes :type.
The mp- commands go under :commands rather than :properties (§3.2),
and the access token never reaches an action. A create is an entry with
an :action:
;; h=entry&content=Hello&category[]=indieweb&category[]=clojure&mp-slug=hello
{:action :create
:type ["h-entry"]
:properties {:content ["Hello"]
:category ["indieweb" "clojure"]}
:commands {:mp-slug ["hello"]}}
A multipart create also has :files by property, e.g. a :photo of Ring's
file maps (:filename, :content-type, :tempfile and :size). An update
keeps the keys of its JSON, and its :delete is either property names or
values by property (§3.4.3):
{:action :update
:url "https://simon.grays.blog/posts/2026/hello"
:replace {:content ["Hello again"]}
:add {:category ["micropub"]}
:delete [:location]}
A delete or an undelete is a map of :action and :url. A query holds the
parameters of the GET, e.g. {:q :source :url "…" :properties [:content]}.
The update itself is (micropub/apply-update entry action). It applies the
replace, add and delete of the action in that order (§3.4 sets none) and
removes a property that has no values left (§3.4.3).
(require '[dk.simongray.micropub.server :as server])
(def opts
{:me-pred #{"https://simon.grays.blog/"}
:tokens token-store
:media-endpoint "https://simon.grays.blog/media"
:create! create!
:update! update!
:delete! delete!
:source source
:upload! upload!
:config {:post-types post-types}
:queries {:category categories}})
(def micropub-handler
(server/handler opts))
(def media-handler
(server/media-handler opts))
The handlers check the token and read the request first. Then each of your
functions gets the action or the query, with the :token-info that
indieauth-clj returns (its :me, :client-id and :scope) and the
:ring-request:
:create! returns the new post's :url for a 201, with :status 202
when the post goes live later (§3.3.6).:source returns the entry at the :url, or nil when there's none. The
handler answers nil with invalid_request, as the example in §3.8 does,
and leaves out the properties that a query doesn't ask for.:update! gets the action with the updated :entry, which apply-update
made from what :source returned. It returns nil for a 204, or the new
:url of a post that moved for a 201 (§3.4.4). So you write only the
mapping in each direction.:delete! and :undelete! return nil for a 204.:upload! gets a file and returns its URL, which the media handler
answers with (§3.6.4). In a multipart create, the URL replaces the file
in the properties, so :create! never sees a file.:config is a map (or a function of the query) for q=config. The handler
adds the media endpoint and the list of queries. It also answers
q=syndicate-to with the config's :syndicate-to (§3.7.3).:queries maps each other query to a function that returns its answer,
e.g. q=category.Any of them can return an error instead (see below), or a promise in
ClojureScript. Each optional function turns its action or query on. An
update needs :source, since §2.1.3 asks for q=source of any server that
updates.
For the tokens, give the :tokens store of an indieauth-clj server in the
same process, or the :introspection-endpoint and :resource-token of one
elsewhere. The handler reads form and JSON bodies itself within
:max-bytes, or takes the params that middleware has already parsed.
server/default-options lists the other defaults, e.g. the scopes of each
action and the status of insufficient_scope.
If you need another shape, the parts are public: server/action and
server/query read a Ring request, and server/problem-response answers
an error.
(require '[dk.simongray.micropub :as micropub])
(micropub/discover! "https://simon.grays.blog/")
;; => {:status 200 :endpoint "https://simon.grays.blog/micropub"}
(micropub/call! (micropub/query-request endpoint token :config))
;; => {:status 200 :body {:media-endpoint "https://simon.grays.blog/media" …}}
(micropub/call! (micropub/request endpoint token
{:type ["h-entry"]
:properties {:content ["Hello"]}}))
;; => {:status 201 :url "https://simon.grays.blog/posts/2026/hello"}
(micropub/call! (micropub/upload-request media-endpoint token file))
;; => {:status 201 :url "https://simon.grays.blog/assets/cat.jpg"}
The request function takes the same actions that server/action returns,
so an action survives the round trip through both. An entry without an
:action is a create, as in Micropub. A create goes form-encoded when its
values are text, since every server takes that (§2.1.3), and as JSON
otherwise. Updates are always JSON (§3.4).
The client sends through wary-fetch's send-public! unless you give a
:send of your own. The token is a hidden text, so it stays out of logs
and behind on a redirect to another site. Discovery reads the Link header
first, as IndieAuth and Webmention do, since §5.3 names no order. Then it
reads only the head of the page: anyone who can write in its body could
otherwise send your posts and your token elsewhere. Give
micropub/discover a response that you fetched yourself for the same
answer.
server/action and
server/query return an error map of :code and :reason, e.g. for a
value that isn't an array in an update (test 405). Your functions return
the same map to refuse an action, e.g. {:error {:code "invalid_request" :reason "No post at that URL"}}. The client's call! returns a server's
error with its :status added, or wary-fetch's failure map when the
request itself failed.:create! or :me-pred, as in websub-clj.server/action, in form-encoded, JSON and multipart syntax.All four are true:
params->post
reads only the name, content, published, category, rsvp, mp-slug and the
four response properties. Everything else goes, photo and syndication
(which an update maps) included. §3.3.5 tells a server to ignore what it
doesn't recognise, so this conforms. But the blog's own media endpoint
hands a client the URL of a photo that the create then drops. Tests 104,
203, 205, 206, 300 and 301 of micropub.rocks post photos too.Three more turned up:
The idea holds. With the library, params->post and
update-property->attr become one table of properties and frontmatter
keys, read in both directions. A create writes an entry, q=source reads
one, and an update reads the post as an entry and writes back what
apply-update makes of it. Three things keep that round trip from losing
anything:
expand-post). The q=source answer already has that
title as the name, and an update that wrote it back would put it in the
frontmatter beside the heading it came from.edit-attr keeps them because it edits the
frontmatter in place, so the writer should merge into the old
frontmatter rather than replace it.From micropub.clj, indieblog deletes:
request-token and authorizefirst-val and the syntax half of params->postupdate-property->attr, edit-attr and apply-updateThe micropub and media functions of the interceptors namespace become the library's two handlers, made with one map of options. It keeps:
frontmatter-block, parse-file, write-post! and list-attrs)derive-slug, unique-slug and url->year+slugThat's about half of the namespace. The authorize test in auth_test.clj would call the handler instead. The blog has no Micropub tests today, so the table should get round-trip tests of its own.
:create! as it is, and a post type in the config can offer events to
clients.About 1,000 lines of source and as many of tests: less than webmention-clj (1,200) and websub-clj (1,600). That's a few days of work, 3 on the ranking's scale. The server takes about half of it.
Version 0.2.0 of indieauth-clj still depends on wary-fetch 0.3.0, html-pieces 0.4.1 and microformats-clj 0.2.0. It needs a release on the versions above before micropub-clj can depend on it. With the second pick below, wary-fetch needs one too.
Each question has my pick and its reason.
multipart-request
or should micropub-clj build the body itself?
Pick: wary-fetch, beside form-request and json-request. It's the
third kind of body that a request can post, and any upload to an API
needs it. That means a wary-fetch release before the client.:update! that
gets the operations alone, for a store that changes properties in place
(e.g. SQL)?
Pick: no, not until such a store turns up. The planned users keep
files or documents, the entry lets the handler enforce the draft rule
on updates, and a second option can come later without breaking
anything.:upload! that /media needs anyway, and
it passes tests 300 and 301. Undelete stays out, as the blog decided.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |