bluesky-tools-0.6.0.0: src/Bluesky/Did.hs
module Bluesky.Did
( Did, rawDid, makeDid, DidError(..)
, Document(alsoKnownAs), getPds
, getDocument
) where
import qualified Data.Aeson as Aeson
import Data.Aeson ((.:))
import qualified Data.Aeson.Types as Aeson
import qualified Data.Char as Char
import qualified Data.Map as Map
import Data.Maybe
import Data.Monoid (First(First, getFirst))
import qualified Data.Text as Text
import Data.Text (Text)
import GHC.Generics
import GHC.Stack
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.URI as URI
-- | https://atproto.com/specs/did
--
-- A DID is a Decentralized Identifier. They're codified by various W3C
-- standards. This type only aims to capture how they are used in atproto.
newtype Did = Did { rawDid :: Text }
deriving stock (Eq, Ord, Show)
deriving newtype (Aeson.ToJSON)
data DidError
= NoDidPrefix
| NoMethodSeparator
-- ^ after "did:", there must be another colon to delimit method from
-- identifier
| BadMethod
-- ^ method must match the regex @[a-z]+@; this does not check if the method
-- is supported by atproto
| BadIdentifierCharacters
-- ^ identifier section must match @[a-zA-Z0-9._:%-]@
-- (nb. that general DID URIs may also have query and fragment components,
-- i.e. @?@ and @#@ characters, but atproto DID identifiers may not)
| EndsWithColon
| BadPercentEncoding
-- ^ the spec says implementations don't need to validate percent encoding,
-- but we validate that every % is followed by two hex digits
deriving stock (Show, Eq, Ord)
makeDid :: Text -> Either DidError Did
makeDid raw
| not ("did:" `Text.isPrefixOf` raw) = Left NoDidPrefix
| Text.null method
|| Text.any (not . isMethodChar) method = Left BadMethod
| Text.any (not . isIdentifierChar) colonBody = Left BadIdentifierCharacters
| Text.takeEnd 1 raw == ":" = Left EndsWithColon
| any (not . Text.all Char.isHexDigit) percentEncodes = Left BadPercentEncoding
| otherwise = Right (Did raw)
where
afterPrefix = Text.drop 4 raw
(method, colonBody) = Text.breakOn ":" afterPrefix
isMethodChar c = Char.isAscii c && Char.isLower c
isIdentifierChar c =
Char.isAscii c
&& (Char.isAlphaNum c || elem c ("._:%-" :: [Char]))
percentEncodes = map (Text.take 2) . drop 1 $ Text.splitOn "%" colonBody
instance Aeson.FromJSON Did where
parseJSON = Aeson.withText "Bluesky.Did.Did" $ either (fail . show) pure . makeDid
-- | The DID methods supported by atproto. Note that DIDs are used outside of
-- atproto and there are many more methods in those contexts, but we don't
-- support them here.
data Method
= Web -- ^ https://w3c-ccg.github.io/did-method-web/
| Plc -- ^ https://github.com/did-method-plc/did-method-plc
deriving stock (Eq, Ord, Show)
getMethod :: Did -> Maybe Method
getMethod Did{ rawDid }
| "did:web:" `Text.isPrefixOf` rawDid = Just Web
| "did:plc:" `Text.isPrefixOf` rawDid = Just Plc
| otherwise = Nothing
data Service = Service
{ serviceId :: Text
, serviceType :: Text
, serviceEndpoint :: URI.URI
} deriving stock (Eq, Ord, Show, Generic)
instance Aeson.FromJSON Service where
parseJSON = Aeson.withObject "Bluesky.Did.Service" $ \o -> do
serviceId <- o .: "id"
serviceType <- o .: "type"
serviceEndpointString <- o .: "serviceEndpoint" -- [sic]
serviceEndpoint <-
maybe
(fail $ "Couldn't parse serviceEndpoint URI: " <> show serviceEndpointString)
pure
$ URI.parseURI serviceEndpointString
pure Service{ serviceId, serviceType, serviceEndpoint }
-- | Fields that the library currently doesn't understand are ignored.
data Document = Document
{ documentId :: Did
, alsoKnownAs :: [Text]
, service :: [Service]
} deriving stock (Eq, Ord, Show, Generic)
getPds :: Document -> Maybe URI.URI
getPds Document{ service } =
getFirst $ foldMap (First . get) service
where
get Service{ serviceId, serviceType, serviceEndpoint }
| "#atproto_pds" `Text.isSuffixOf` serviceId
&& serviceType == "AtprotoPersonalDataServer"
= Just serviceEndpoint
| otherwise = Nothing
genericParseJSONMapFields
:: (Generic a, Aeson.GFromJSON Aeson.Zero (Rep a))
=> [(String, String)] -> Aeson.Value -> Aeson.Parser a
genericParseJSONMapFields fields =
Aeson.genericParseJSON
Aeson.defaultOptions{ Aeson.fieldLabelModifier = mapFields }
where
mapFields field = fromMaybe field (Map.lookup field fieldsMap)
fieldsMap = Map.fromList fields
instance Aeson.FromJSON Document where
parseJSON =
genericParseJSONMapFields
[("documentId", "id")]
-- | This is currently only implemented for did:plc: DIDs.
getDocument :: HasCallStack => HTTP.Manager -> Did -> IO (Maybe Document)
getDocument httpManager did@(Did rawDid) =
case getMethod did of
Nothing -> error "Unknown DID method"
Just Web -> error "Support for did:web: is not yet implemented"
Just Plc -> do
req <-
HTTP.parseRequest
("https://plc.directory/" <> Text.unpack rawDid)
resp <- HTTP.httpLbs req httpManager
case HTTP.statusCode (HTTP.responseStatus resp) of
404 -> pure Nothing
200 -> either fail (pure . Just) $ Aeson.eitherDecode $ HTTP.responseBody resp
other -> fail $ "Unexpected HTTP status " <> show other