about summary refs log tree commit diff
path: root/users/Profpatsch/whatcd-resolver/src
diff options
context:
space:
mode:
authorProfpatsch <mail@profpatsch.de>2023-10-15T22·48+0200
committerclbot <clbot@tvl.fyi>2023-10-15T22·57+0000
commit81b790af1d8a49b52376c566183591396f60fb3e (patch)
tree33485e2676f6fe16f28f92e1f2ec74fc4cb43a41 /users/Profpatsch/whatcd-resolver/src
parentdf43454dc5cea21d173df7385c3576318cb71211 (diff)
feat(users/Profpatsch/whatcd-resolver): start checking musicbrainz r/6819
Ideally we can figure out how to search for single songs by grepping
through musicbrainz. For this we kinda need the jsonld results, so
this is a first step which visualizes the structure and makes it
easy-ish to lazily traverse it.

Change-Id: Ieca21674dee8e8c2dacbab4f2f15ccbe067da647
Reviewed-on: https://cl.tvl.fyi/c/depot/+/9743
Reviewed-by: Profpatsch <mail@profpatsch.de>
Autosubmit: Profpatsch <mail@profpatsch.de>
Tested-by: BuildkiteCI
Diffstat (limited to 'users/Profpatsch/whatcd-resolver/src')
-rw-r--r--users/Profpatsch/whatcd-resolver/src/WhatcdResolver.hs242
1 files changed, 220 insertions, 22 deletions
diff --git a/users/Profpatsch/whatcd-resolver/src/WhatcdResolver.hs b/users/Profpatsch/whatcd-resolver/src/WhatcdResolver.hs
index 3b1dec9669f4..858b8a000dec 100644
--- a/users/Profpatsch/whatcd-resolver/src/WhatcdResolver.hs
+++ b/users/Profpatsch/whatcd-resolver/src/WhatcdResolver.hs
@@ -6,12 +6,16 @@
 module WhatcdResolver where
 
 import Control.Category qualified as Cat
+import Control.Monad.Catch.Pure (runCatch)
+import Control.Monad.Error (catchError)
+import Control.Monad.Except (runExcept)
 import Control.Monad.Logger qualified as Logger
 import Control.Monad.Logger.CallStack
 import Control.Monad.Reader
 import Data.Aeson qualified as Json
 import Data.Aeson.BetterErrors qualified as Json
 import Data.Aeson.KeyMap qualified as KeyMap
+import Data.ByteString.Builder qualified as Builder
 import Data.Error.Tree
 import Data.HashMap.Strict qualified as HashMap
 import Data.List qualified as List
@@ -19,13 +23,15 @@ import Data.List.NonEmpty qualified as NonEmpty
 import Data.Map.Strict qualified as Map
 import Data.Pool (Pool)
 import Data.Pool qualified as Pool
+import Data.Set (Set)
+import Data.Set qualified as Set
 import Data.Text qualified as Text
 import Database.PostgreSQL.Simple (Binary (Binary), Only (..))
 import Database.PostgreSQL.Simple qualified as Postgres
 import Database.PostgreSQL.Simple.SqlQQ (sql)
 import Database.PostgreSQL.Simple.Types (PGArray (PGArray))
 import Database.Postgres.Temp qualified as TmpPg
-import FieldParser (FieldParser' (..))
+import FieldParser (FieldParser, FieldParser' (..))
 import FieldParser qualified as Field
 import GHC.Records (HasField (..))
 import GHC.Stack qualified
@@ -35,16 +41,21 @@ import Json.Enc (Enc)
 import Json.Enc qualified as Enc
 import Label
 import Multipart2 qualified as Multipart
+import Network.HTTP.Client.Conduit qualified as Http
 import Network.HTTP.Conduit qualified as Http
 import Network.HTTP.Simple qualified as Http
 import Network.HTTP.Types
 import Network.HTTP.Types qualified as Http
+import Network.HTTP.Types.URI qualified as Url
+import Network.URI qualified
 import Network.Wai qualified as Wai
 import Network.Wai.Handler.Warp qualified as Warp
 import Network.Wai.Parse qualified as Wai
 import OpenTelemetry.Trace qualified as Otel hiding (getTracer, inSpan, inSpan')
 import OpenTelemetry.Trace.Core qualified as Otel hiding (inSpan, inSpan')
 import OpenTelemetry.Trace.Monad qualified as Otel
+import Parse (Parse)
+import Parse qualified
 import PossehlAnalyticsPrelude
 import Postgres.Decoder qualified as Dec
 import Postgres.MonadPostgres
@@ -119,9 +130,12 @@ htmlUi = do
               ( do
                   label @"torrentId" <$> Multipart.field "torrent-id" ((Field.utf8 >>> Field.signedDecimal >>> Field.bounded @Int "int"))
               )
+      let parseQueryArgs span parser =
+            Parse.runParse "Unable to find the right request query arguments" (lmap Wai.queryString parser) req
+              & assertM span id
 
       case req & Wai.pathInfo & Text.intercalate "/" of
-        "" -> h "/" (\_span -> mainHtml)
+        "" -> h "/" mainHtml
         "snips/redacted/search" -> do
           h "/snips/redacted/search" $ \span -> do
             dat <-
@@ -190,14 +204,27 @@ htmlUi = do
             case status of
               Nothing -> [hsx|ERROR unknown|]
               Just _torrent -> [hsx|Running|]
-        otherRoute -> h [fmt|/{otherRoute}|] (\_span -> mainHtml)
+        "snips/jsonld/render" ->
+          h "/snips/jsonld/render" $ \span -> do
+            qry <-
+              parseQueryArgs
+                span
+                ( label @"target"
+                    <$> ( singleQueryArgument "target" Field.utf8
+                            >>> textToHttpClientRequest
+                        )
+                )
+            jsonld <- httpGetJsonLd span (qry.target)
+            pure $ renderJsonld jsonld
+        otherRoute -> h [fmt|/{otherRoute}|] mainHtml
   where
     everySecond :: Text -> Enc -> Html -> Html
     everySecond call extraData innerHtml = [hsx|<div hx-trigger="every 1s" hx-swap="outerHTML" hx-post={call} hx-vals={Enc.encToBytesUtf8 extraData}>{innerHtml}</div>|]
 
-    mainHtml = runTransaction $ do
+    mainHtml span = runTransaction $ do
+      jsonld <- httpGetJsonLd span "https://musicbrainz.org/work/92000fd4-d304-406d-aeb4-6bdbeed318ec" <&> renderJsonld
       bestTorrentsTable <- getBestTorrentsTable
-      transmissionTorrentsTable <- lift @Transaction getTransmissionTorrentsTable
+      -- transmissionTorrentsTable <- lift @Transaction getTransmissionTorrentsTable
       pure $
         Html.docTypeHtml
           [hsx|
@@ -207,8 +234,16 @@ htmlUi = do
         <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
         <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
         <script src="https://unpkg.com/htmx.org@1.9.2" integrity="sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h" crossorigin="anonymous"></script>
+        <style>
+          dl {
+            margin: 1em;
+            padding: 0.5em 1em;
+            border: thin solid;
+          }
+        </style>
       </head>
       <body>
+        {jsonld}
         <form
           hx-post="/snips/redacted/search"
           hx-target="#redacted-search-results">
@@ -217,17 +252,66 @@ htmlUi = do
             id="redacted-search"
             type="text"
             name="redacted-search" />
-          <button type="submit">Search</button>
+          <button type="submit" hx-disabled-elt="this">Search</button>
+          <div class="htmx-indicator">Search running!</div>
         </form>
         <div id="redacted-search-results">
           {bestTorrentsTable}
         </div>
-        <div id="transmission-torrents">
-          {transmissionTorrentsTable}
-        </div>
       </body>
     |]
 
+singleQueryArgument :: Text -> FieldParser ByteString to -> Parse Http.Query to
+singleQueryArgument field inner =
+  Parse.mkParsePushContext
+    field
+    ( \(ctx, qry) -> case qry
+        & mapMaybe
+          ( \(k, v) ->
+              if k == (field & textToBytesUtf8)
+                then Just v
+                else Nothing
+          ) of
+        [] -> Left [fmt|No such query argument "{field}", at {ctx & Parse.showContext}|]
+        [Nothing] -> Left [fmt|Expected one query argument with a value, but "{field}" was a query flag|]
+        [Just one] -> Right one
+        more -> Left [fmt|More than one value for query argument "{field}": {show more}, at {ctx & Parse.showContext}|]
+    )
+    >>> Parse.fieldParser inner
+
+-- | Make sure we can parse the given Text into a Request via a URI.
+--
+-- This tries to work around the horrible, horrible interface in Http.Client.
+textToHttpClientRequest :: Parse Text Http.Request
+textToHttpClientRequest =
+  Parse.fieldParser
+    ( FieldParser $ \text ->
+        text
+          & textToString
+          & Network.URI.parseURI
+          & annotate [fmt|Cannot parse this as a URL: "{text}"|]
+    )
+    >>> ( Parse.mkParseNoContext
+            ( \url ->
+                (url & Http.requestFromURI)
+                  & runCatch
+                  & first (checkException @Http.HttpException)
+                  & \case
+                    Left (Right (Http.InvalidUrlException urlText reason)) ->
+                      Left [fmt|Unable to set the url "{urlText}" as request URL, reason: {reason}|]
+                    Left (Right exc@(Http.HttpExceptionRequest _ _)) ->
+                      Left [fmt|Weird! Should not get a HttpExceptionRequest when parsing an URL (bad library design), was {exc & displayException}|]
+                    Left (Left someExc) ->
+                      Left [fmt|Weird! Should not get anyhting but a HttpException when parsing an URL (bad library design), was {someExc & displayException}|]
+                    Right req -> pure req
+            )
+        )
+
+checkException :: (Exception b) => SomeException -> Either SomeException b
+checkException some = case fromException some of
+  Nothing -> Left some
+  Just e -> Right e
+
 snipsRedactedSearch ::
   ( MonadLogger m,
     MonadPostgres m,
@@ -324,6 +408,78 @@ getBestTorrentsTable = do
         </table>
       |]
 
+data Jsonld
+  = JsonldObject JsonldObject
+  | JsonldArray [Jsonld]
+  | JsonldField Json.Value
+  deriving stock (Show, Eq)
+
+data JsonldObject = JsonldObject'
+  { type_ :: Set Text,
+    id_ :: Text,
+    previewFields :: Map Text Jsonld
+  }
+  deriving stock (Show, Eq)
+
+jsonldParser :: (Monad m) => Json.ParseT err m Jsonld
+jsonldParser =
+  Json.asValue >>= \cur -> do
+    if
+        | Json.Object _ <- cur -> do
+            typeMay <- Json.keyMay "@type" $ (Json.asArraySet Json.asText Json.<|> (Set.singleton <$> Json.asText))
+            idMay <- Json.keyMay "@id" $ Json.asText
+            if
+                | Just type_ <- typeMay,
+                  Just id_ <- idMay -> do
+                    previewFields <-
+                      Json.asObjectMap jsonldParser
+                        <&> Map.delete "@type"
+                        <&> Map.delete "@id"
+                    pure $ JsonldObject $ JsonldObject' {..}
+                | otherwise -> pure $ JsonldField cur
+        | Json.Array _ <- cur -> do
+            JsonldArray <$> Json.eachInArray jsonldParser
+        | otherwise -> pure $ JsonldField cur
+
+renderJsonld :: Jsonld -> Html
+renderJsonld = \case
+  JsonldObject obj ->
+    [hsx|
+    <dl>
+      <dt>Type</dt>
+      <dd>{obj.type_ & toList & schemaTypes}</dd>
+      <dt>Url</dt>
+      <dd><a href={obj.id_}>{obj.id_}</a></dd>
+      <dt>Fields</dt>
+      <dd>
+        {obj.previewFields & toDefinitionList renderJsonld}
+        <div>
+          <button
+            hx-get={snippetHref obj.id_}
+            hx-target="closest dl"
+            hx-swap="outerHTML"
+          >more fields …</button>
+        </div>
+      </dd>
+    </dl>
+  |]
+    where
+      snippetHref target =
+        Builder.toLazyByteString $
+          "/snips/jsonld/render"
+            <> Url.renderQueryBuilder True [("target", Just (textToBytesUtf8 target))]
+
+      schemaTypes xs =
+        xs
+          <&> ( \t ->
+                  let href :: Text = [fmt|https://schema.org/{t}|] in [hsx|<a href={href} target="_blank">{t}</a>|]
+              )
+          & List.intersperse ", "
+          & mconcat
+  JsonldArray arr ->
+    toOrderedList renderJsonld arr
+  JsonldField f -> mkVal f
+
 -- | A value between (inclusive) 0% and (inclusive) 100%. Precise to 1% steps.
 newtype Percentage = Percentage {unPercentage :: Int}
   deriving stock (Show)
@@ -409,16 +565,31 @@ mkVal = \case
   Json.Bool True -> [hsx|<em>true</em>|]
   Json.Bool False -> [hsx|<em>false</em>|]
   Json.Null -> [hsx|<em>null</em>|]
-  Json.Array arr ->
-    arr
-      & foldMap (\el -> Html.li $ mkVal el)
-      & Html.ol
+  Json.Array arr -> toOrderedList mkVal arr
   Json.Object obj ->
     obj
       & KeyMap.toMapText
-      & Map.toList
-      & foldMap (\(k, v) -> Html.dt (Html.toHtml @Text k) <> Html.dd (mkVal v))
-      & Html.dl
+      & toDefinitionList mkVal
+
+toOrderedList :: (Foldable t1) => (t2 -> Html) -> t1 t2 -> Html
+toOrderedList mkValFn arr =
+  arr
+    & foldMap (\el -> Html.li $ mkValFn el)
+    & Html.ol
+
+toUnorderedList :: (Foldable t1) => (t2 -> Html) -> t1 t2 -> Html
+toUnorderedList mkValFn arr =
+  arr
+    & foldMap (\el -> Html.li $ mkValFn el)
+    & Html.ul
+
+-- | Render a definition list from a Map
+toDefinitionList :: (t -> Html) -> Map Text t -> Html
+toDefinitionList mkValFn obj =
+  obj
+    & Map.toList
+    & foldMap (\(k, v) -> Html.dt (Html.toHtml @Text k) <> Html.dd (mkValFn v))
+    & Html.dl
 
 -- | Render a table-like structure of json values as an HTML table.
 toTable :: [[(Text, Json.Value)]] -> Html
@@ -1151,6 +1322,17 @@ mkRedactedApiRequest dat = do
       & Http.setQueryString (("action", Just dat.action) : dat.actionArgs)
       & Http.setRequestHeader "Authorization" [authKey]
 
+httpGetJsonLd :: (MonadIO m, MonadThrow m) => Otel.Span -> Http.Request -> m Jsonld
+httpGetJsonLd span req = do
+  httpJson
+    (mkOptional (label @"contentType" "application/ld+json"))
+    span
+    jsonldParser
+    ( req
+        & Http.setRequestMethod "GET"
+        & Http.setRequestHeader "Accept" ["application/ld+json"]
+    )
+
 httpTorrent ::
   ( MonadIO m,
     MonadThrow m
@@ -1183,15 +1365,30 @@ httpTorrent span req =
               | code <- statusCode -> Left [fmt|Redacted returned an non-200 error code, code {code}: {resp & showPretty}|]
       )
 
+newtype Optional a = OptionalInternal (Maybe a)
+
+mkOptional :: a -> Optional a
+mkOptional defaultValue = OptionalInternal $ Just defaultValue
+
+defaults :: Optional a
+defaults = OptionalInternal Nothing
+
+instance HasField "withDefault" (Optional a) (a -> a) where
+  getField (OptionalInternal m) defaultValue = case m of
+    Nothing -> defaultValue
+    Just a -> a
+
 httpJson ::
   ( MonadIO m,
     MonadThrow m
   ) =>
+  (Optional (Label "contentType" ByteString)) ->
   Otel.Span ->
   Json.Parse ErrorTree b ->
   Http.Request ->
   m b
-httpJson span parser req =
+httpJson opts span parser req = do
+  let opts' = opts.withDefault (label @"contentType" "application/json")
   Http.httpBS req
     >>= assertM
       span
@@ -1205,15 +1402,16 @@ httpJson span parser req =
                   <&> (\(ct, _mimeAttributes) -> ct)
           if
               | statusCode == 200,
-                Just "application/json" <- contentType ->
+                Just ct <- contentType,
+                ct == opts'.contentType ->
                   Right $ (resp & Http.responseBody)
               | statusCode == 200,
                 Just otherType <- contentType ->
-                  Left [fmt|Redacted returned a non-json body, with content-type "{otherType}"|]
+                  Left [fmt|Server returned a non-json body, with content-type "{otherType}"|]
               | statusCode == 200,
                 Nothing <- contentType ->
-                  Left [fmt|Redacted returned a body with unspecified content type|]
-              | code <- statusCode -> Left [fmt|Redacted returned an non-200 error code, code {code}: {resp & showPretty}|]
+                  Left [fmt|Server returned a body with unspecified content type|]
+              | code <- statusCode -> Left [fmt|Server returned an non-200 error code, code {code}: {resp & showPretty}|]
       )
     >>= assertM
       span
@@ -1236,7 +1434,7 @@ redactedApiRequestJson ::
 redactedApiRequestJson span dat parser =
   do
     mkRedactedApiRequest dat
-    >>= httpJson span parser
+    >>= httpJson defaults span parser
 
 assertM :: (MonadThrow f, MonadIO f) => Otel.Span -> (t -> Either ErrorTree a) -> t -> f a
 assertM span f v = case f v of