This post is heavily based on "Crossposting to DevTo via Command Line". However, that is not a prerequisite reading.
Let's see how to crosspost a local Jekyll-like blog post to Medium. First, we have the crosspost
function:
crosspost :: Text -> Text -> IO ()
crosspost apiKey path = do
-- ^ Medium API key.
-- ^ Filepath to the blog post to crosspost.
f <- Data.ByteString.readFile . Data.Text.unpack $ path
case parseYamlFrontmatter f of
-- ^ Parse the frontmatter (and content) of the blog post to crosspost.
Done postBS frontmatter -> do
-- ^ If the frontmatter (and content) was parsed successfully..
let opts =
defaults
& Network.Wreq.auth ?~ Network.Wreq.oauth2Bearer (encodeUtf8 apiKey)
r <- Network.Wreq.getWith opts "https://api.medium.com/v1/me"
let id_ = r ^. responseBody . key "data" . key "id" . _String
-- ^ ..then get the user id associated with the API key and..
let post = decodeUtf8 postBS
let json = toJSON $ mkMediumPost path frontmatter post
-- ^ ..create the JSON body for the Medium endpoint and..
let opts2 =
defaults
& Network.Wreq.auth ?~ Network.Wreq.oauth2Bearer (encodeUtf8 apiKey)
& Network.Wreq.header "Content-Type" .~ [encodeUtf8 "application/json; charset=utf-8"]
r2 <-
Network.Wreq.postWith
-- ^ ..post the request to Medium to create the blog post.
opts
("https://api.medium.com/v1/users/" <> Data.Text.unpack id_ <> "/posts")
json
print $ r2 ^? responseBody
e ->
error $ show e
-- ^ ..else stop execution and display the error `e`.
To perform HTTP request we use Wreq which employs optics (e.g. .~
, ^.
). Of course, any other HTTP package would have been ok. Just wanted to have some fun.
The data sent to the Medium endpoint is represented by the MediumPost
type:
-- `MediumPost` is what we send to Medium.
data MediumPost = MediumPost
{ title :: Text,
tags :: [Text],
canonicalUrl :: Text,
publishStatus :: Text,
content :: Text,
contentFormat :: Text,
notifyFollowers :: Bool
}
deriving (Show, Generic)
instance ToJSON MediumPost where
toJSON MediumPost {..} =
object
[ "title" .= title,
"tags" .= tags,
"canonicalUrl" .= canonicalUrl,
"publishStatus" .= publishStatus,
"content" .= content,
"contentFormat" .= contentFormat,
"notifyFollowers" .= notifyFollowers
]
-- `Front` is what we parse from the local blog post.
data Front = Front
{ title :: Text,
description :: Text,
tags :: [Text]
}
deriving (Show, Generic, FromJSON)
mkMediumPost :: Text -> Front -> Text -> MediumPost
mkMediumPost path Front {..} post = MediumPost {..}
-- ^ Same as `{ title = title, description = description, tags = tags }`.
-- Enabled by {-# LANGUAGE RecordWildCards #-}.
where
publishStatus = "draft"
content = fold ["Originally posted on", " ", "[odone.io](", canonicalUrl, ").\n\n---\n\n", post]
contentFormat = "markdown"
canonicalUrl = urlFor path
notifyFollowers = True
-- URL of the blog post on odone.io.
urlFor :: Text -> Text
urlFor path = fold [base, "/", name, ".html"]
where
base = "https://odone.io/posts"
name = Data.Text.pack . System.FilePath.Posix.takeBaseName . Data.Text.unpack $ path
We then use optparse-applicative to get the inputs needed from the command line. Its readme is awesome, so please refer to that to learn more.
main :: IO ()
main = uncurry crosspost =<< execParser opts
-- ^ Parses the command line input and returns a tuple (String, String).
-- ^ `uncurry` converts a function on two arguments to a function expecting a tuple.
where
opts =
info
(parser <**> helper)
( fullDesc
<> progDesc "Crossposts POST to Medium"
)
parser :: Options.Applicative.Parser (Text, Text)
parser =
(,)
<$> Options.Applicative.argument
str
-- ^ The first mandatory argument is the API key to Medium.
( metavar "API_KEY"
<> help "API_KEY to post on Medium"
)
<*> Options.Applicative.argument
str
-- ^ The second mandatory argument is the path to the blog post to crosspost.
( metavar "POST"
<> help "Path to blog POST to crosspost"
)
With that in place, calling the script without the mandatory arguments we get:
./tomedium.hs
# Missing: API_KEY POST
#
# Usage: tomedium.hs API_KEY POST
# Crossposts POST to Medium
We can also call it with --help
to get a detailed explanation:
./tomedium.hs --help
# Usage: tomedium.hs API_KEY POST
# Crossposts POST to Medium
#
# Available options:
# API_KEY API_KEY to post on Medium
# POST Path to blog POST to crosspost
# -h,--help Show this help text
A proper call adds an unpublished blog post on Medium with all the following filled properly:
- title;
- description;
- tags;
- canonical_url (the URL of the post on odone.io);
- content.
The whole script is on GitHub.
The fact that the script is based on the one for DevTo and written in Haskell made my life really easy. Yet another great reason to write scripts using static strong types.