mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 02:18:26 +00:00
Moved route definitions in RestApp
This commit is contained in:
@ -7,8 +7,9 @@
|
|||||||
package docspell.restserver
|
package docspell.restserver
|
||||||
|
|
||||||
import fs2.Stream
|
import fs2.Stream
|
||||||
|
|
||||||
import docspell.backend.BackendApp
|
import docspell.backend.BackendApp
|
||||||
|
import org.http4s.HttpRoutes
|
||||||
|
import org.http4s.server.websocket.WebSocketBuilder2
|
||||||
|
|
||||||
trait RestApp[F[_]] {
|
trait RestApp[F[_]] {
|
||||||
|
|
||||||
@ -25,4 +26,7 @@ trait RestApp[F[_]] {
|
|||||||
* via websocket.
|
* via websocket.
|
||||||
*/
|
*/
|
||||||
def subscriptions: Stream[F, Nothing]
|
def subscriptions: Stream[F, Nothing]
|
||||||
|
|
||||||
|
/** Http4s endpoint definitions. */
|
||||||
|
def routes(wsb: WebSocketBuilder2[F]): HttpRoutes[F]
|
||||||
}
|
}
|
||||||
|
@ -9,22 +9,30 @@ package docspell.restserver
|
|||||||
import cats.effect._
|
import cats.effect._
|
||||||
import fs2.Stream
|
import fs2.Stream
|
||||||
import fs2.concurrent.Topic
|
import fs2.concurrent.Topic
|
||||||
|
|
||||||
import docspell.backend.BackendApp
|
import docspell.backend.BackendApp
|
||||||
|
import docspell.backend.auth.{AuthToken, ShareToken}
|
||||||
import docspell.ftsclient.FtsClient
|
import docspell.ftsclient.FtsClient
|
||||||
import docspell.ftssolr.SolrFtsClient
|
import docspell.ftssolr.SolrFtsClient
|
||||||
import docspell.notification.api.NotificationModule
|
import docspell.notification.api.NotificationModule
|
||||||
import docspell.notification.impl.NotificationModuleImpl
|
import docspell.notification.impl.NotificationModuleImpl
|
||||||
|
import docspell.oidc.CodeFlowRoutes
|
||||||
import docspell.pubsub.api.{PubSub, PubSubT}
|
import docspell.pubsub.api.{PubSub, PubSubT}
|
||||||
import docspell.restserver.ws.OutputEvent
|
import docspell.restserver.auth.OpenId
|
||||||
|
import docspell.restserver.http4s.EnvMiddleware
|
||||||
|
import docspell.restserver.routes._
|
||||||
|
import docspell.restserver.webapp.{TemplateRoutes, Templates, WebjarRoutes}
|
||||||
|
import docspell.restserver.ws.{OutputEvent, WebSocketRoutes}
|
||||||
import docspell.store.Store
|
import docspell.store.Store
|
||||||
|
|
||||||
import emil.javamail.JavaMailEmil
|
import emil.javamail.JavaMailEmil
|
||||||
|
import org.http4s.HttpRoutes
|
||||||
import org.http4s.client.Client
|
import org.http4s.client.Client
|
||||||
|
import org.http4s.server.Router
|
||||||
|
import org.http4s.server.websocket.WebSocketBuilder2
|
||||||
|
|
||||||
final class RestAppImpl[F[_]: Async](
|
final class RestAppImpl[F[_]: Async](
|
||||||
val config: Config,
|
val config: Config,
|
||||||
val backend: BackendApp[F],
|
val backend: BackendApp[F],
|
||||||
|
httpClient: Client[F],
|
||||||
notificationMod: NotificationModule[F],
|
notificationMod: NotificationModule[F],
|
||||||
wsTopic: Topic[F, OutputEvent],
|
wsTopic: Topic[F, OutputEvent],
|
||||||
pubSub: PubSubT[F]
|
pubSub: PubSubT[F]
|
||||||
@ -35,6 +43,107 @@ final class RestAppImpl[F[_]: Async](
|
|||||||
|
|
||||||
def subscriptions: Stream[F, Nothing] =
|
def subscriptions: Stream[F, Nothing] =
|
||||||
Subscriptions[F](wsTopic, pubSub)
|
Subscriptions[F](wsTopic, pubSub)
|
||||||
|
|
||||||
|
def routes(wsb: WebSocketBuilder2[F]): HttpRoutes[F] =
|
||||||
|
createHttpApp(wsb)
|
||||||
|
|
||||||
|
val templates = TemplateRoutes[F](config, Templates[F])
|
||||||
|
|
||||||
|
def createHttpApp(
|
||||||
|
wsB: WebSocketBuilder2[F]
|
||||||
|
) =
|
||||||
|
Router(
|
||||||
|
"/api/info" -> InfoRoutes(),
|
||||||
|
"/api/v1/open/" -> openRoutes(httpClient),
|
||||||
|
"/api/v1/sec/" -> Authenticate(backend.login, config.auth) { token =>
|
||||||
|
securedRoutes(wsB, token)
|
||||||
|
},
|
||||||
|
"/api/v1/admin" -> AdminAuth(config.adminEndpoint) {
|
||||||
|
adminRoutes
|
||||||
|
},
|
||||||
|
"/api/v1/share" -> ShareAuth(backend.share, config.auth) { token =>
|
||||||
|
shareRoutes(token)
|
||||||
|
},
|
||||||
|
"/api/doc" -> templates.doc,
|
||||||
|
"/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]),
|
||||||
|
"/app" -> EnvMiddleware(templates.app),
|
||||||
|
"/sw.js" -> EnvMiddleware(templates.serviceWorker)
|
||||||
|
)
|
||||||
|
|
||||||
|
def adminRoutes: HttpRoutes[F] =
|
||||||
|
Router(
|
||||||
|
"fts" -> FullTextIndexRoutes.admin(config, backend),
|
||||||
|
"user/otp" -> TotpRoutes.admin(backend),
|
||||||
|
"user" -> UserRoutes.admin(backend),
|
||||||
|
"info" -> InfoRoutes.admin(config),
|
||||||
|
"attachments" -> AttachmentRoutes.admin(backend)
|
||||||
|
)
|
||||||
|
|
||||||
|
def shareRoutes(
|
||||||
|
token: ShareToken
|
||||||
|
): HttpRoutes[F] =
|
||||||
|
Router(
|
||||||
|
"search" -> ShareSearchRoutes(backend, config, token),
|
||||||
|
"attachment" -> ShareAttachmentRoutes(backend, token),
|
||||||
|
"item" -> ShareItemRoutes(backend, token),
|
||||||
|
"clientSettings" -> ClientSettingsRoutes.share(backend, token)
|
||||||
|
)
|
||||||
|
def openRoutes(
|
||||||
|
client: Client[F]
|
||||||
|
): HttpRoutes[F] =
|
||||||
|
Router(
|
||||||
|
"auth/openid" -> CodeFlowRoutes(
|
||||||
|
config.openIdEnabled,
|
||||||
|
OpenId.handle[F](backend, config),
|
||||||
|
OpenId.codeFlowConfig(config),
|
||||||
|
client
|
||||||
|
),
|
||||||
|
"auth" -> LoginRoutes.login(backend.login, config),
|
||||||
|
"signup" -> RegisterRoutes(backend, config),
|
||||||
|
"upload" -> UploadRoutes.open(backend, config),
|
||||||
|
"checkfile" -> CheckFileRoutes.open(backend),
|
||||||
|
"integration" -> IntegrationEndpointRoutes.open(backend, config),
|
||||||
|
"share" -> ShareRoutes.verify(backend, config)
|
||||||
|
)
|
||||||
|
|
||||||
|
def securedRoutes(
|
||||||
|
wsB: WebSocketBuilder2[F],
|
||||||
|
token: AuthToken
|
||||||
|
): HttpRoutes[F] =
|
||||||
|
Router(
|
||||||
|
"ws" -> WebSocketRoutes(token, backend, wsTopic, wsB),
|
||||||
|
"auth" -> LoginRoutes.session(backend.login, config, token),
|
||||||
|
"tag" -> TagRoutes(backend, token),
|
||||||
|
"equipment" -> EquipmentRoutes(backend, token),
|
||||||
|
"organization" -> OrganizationRoutes(backend, token),
|
||||||
|
"person" -> PersonRoutes(backend, token),
|
||||||
|
"source" -> SourceRoutes(backend, token),
|
||||||
|
"user/otp" -> TotpRoutes(backend, config, token),
|
||||||
|
"user" -> UserRoutes(backend, token),
|
||||||
|
"collective" -> CollectiveRoutes(backend, token),
|
||||||
|
"queue" -> JobQueueRoutes(backend, token),
|
||||||
|
"item" -> ItemRoutes(config, backend, token),
|
||||||
|
"items" -> ItemMultiRoutes(config, backend, token),
|
||||||
|
"attachment" -> AttachmentRoutes(backend, token),
|
||||||
|
"attachments" -> AttachmentMultiRoutes(backend, token),
|
||||||
|
"upload" -> UploadRoutes.secured(backend, config, token),
|
||||||
|
"checkfile" -> CheckFileRoutes.secured(backend, token),
|
||||||
|
"email/send" -> MailSendRoutes(backend, token),
|
||||||
|
"email/settings" -> MailSettingsRoutes(backend, token),
|
||||||
|
"email/sent" -> SentMailRoutes(backend, token),
|
||||||
|
"share" -> ShareRoutes.manage(backend, token),
|
||||||
|
"usertask/notifydueitems" -> NotifyDueItemsRoutes(config, backend, token),
|
||||||
|
"usertask/scanmailbox" -> ScanMailboxRoutes(backend, token),
|
||||||
|
"usertask/periodicquery" -> PeriodicQueryRoutes(config, backend, token),
|
||||||
|
"calevent/check" -> CalEventCheckRoutes(),
|
||||||
|
"fts" -> FullTextIndexRoutes.secured(config, backend, token),
|
||||||
|
"folder" -> FolderRoutes(backend, token),
|
||||||
|
"customfield" -> CustomFieldRoutes(backend, token),
|
||||||
|
"clientSettings" -> ClientSettingsRoutes(backend, token),
|
||||||
|
"notification" -> NotificationRoutes(config, backend, token),
|
||||||
|
"querybookmark" -> BookmarkRoutes(backend, token)
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object RestAppImpl {
|
object RestAppImpl {
|
||||||
@ -58,7 +167,14 @@ object RestAppImpl {
|
|||||||
backend <- BackendApp
|
backend <- BackendApp
|
||||||
.create[F](store, javaEmil, ftsClient, pubSubT, notificationMod)
|
.create[F](store, javaEmil, ftsClient, pubSubT, notificationMod)
|
||||||
|
|
||||||
app = new RestAppImpl[F](cfg, backend, notificationMod, wsTopic, pubSubT)
|
app = new RestAppImpl[F](
|
||||||
|
cfg,
|
||||||
|
backend,
|
||||||
|
httpClient,
|
||||||
|
notificationMod,
|
||||||
|
wsTopic,
|
||||||
|
pubSubT
|
||||||
|
)
|
||||||
} yield app
|
} yield app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,30 +7,21 @@
|
|||||||
package docspell.restserver
|
package docspell.restserver
|
||||||
|
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
import cats.effect._
|
import cats.effect._
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
import fs2.Stream
|
import fs2.Stream
|
||||||
import fs2.concurrent.Topic
|
import fs2.concurrent.Topic
|
||||||
|
|
||||||
import docspell.backend.auth.{AuthToken, ShareToken}
|
|
||||||
import docspell.backend.msg.Topics
|
import docspell.backend.msg.Topics
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.oidc.CodeFlowRoutes
|
|
||||||
import docspell.pubsub.naive.NaivePubSub
|
import docspell.pubsub.naive.NaivePubSub
|
||||||
import docspell.restserver.auth.OpenId
|
import docspell.restserver.http4s.InternalHeader
|
||||||
import docspell.restserver.http4s.{EnvMiddleware, InternalHeader}
|
|
||||||
import docspell.restserver.routes._
|
|
||||||
import docspell.restserver.webapp._
|
|
||||||
import docspell.restserver.ws.OutputEvent.KeepAlive
|
import docspell.restserver.ws.OutputEvent.KeepAlive
|
||||||
import docspell.restserver.ws.{OutputEvent, WebSocketRoutes}
|
import docspell.restserver.ws.OutputEvent
|
||||||
import docspell.store.Store
|
import docspell.store.Store
|
||||||
import docspell.store.records.RInternalSetting
|
import docspell.store.records.RInternalSetting
|
||||||
|
|
||||||
import org.http4s._
|
import org.http4s._
|
||||||
import org.http4s.blaze.client.BlazeClientBuilder
|
import org.http4s.blaze.client.BlazeClientBuilder
|
||||||
import org.http4s.blaze.server.BlazeServerBuilder
|
import org.http4s.blaze.server.BlazeServerBuilder
|
||||||
import org.http4s.client.Client
|
|
||||||
import org.http4s.dsl.Http4sDsl
|
import org.http4s.dsl.Http4sDsl
|
||||||
import org.http4s.headers.Location
|
import org.http4s.headers.Location
|
||||||
import org.http4s.implicits._
|
import org.http4s.implicits._
|
||||||
@ -51,7 +42,7 @@ object RestServer {
|
|||||||
server =
|
server =
|
||||||
Stream
|
Stream
|
||||||
.resource(createApp(cfg, pools, wsTopic))
|
.resource(createApp(cfg, pools, wsTopic))
|
||||||
.flatMap { case (restApp, pubSub, httpClient, setting) =>
|
.flatMap { case (restApp, pubSub, setting) =>
|
||||||
Stream(
|
Stream(
|
||||||
restApp.subscriptions,
|
restApp.subscriptions,
|
||||||
restApp.eventConsume(2),
|
restApp.eventConsume(2),
|
||||||
@ -59,7 +50,7 @@ object RestServer {
|
|||||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||||
.withoutBanner
|
.withoutBanner
|
||||||
.withHttpWebSocketApp(
|
.withHttpWebSocketApp(
|
||||||
createHttpApp(cfg, setting, httpClient, pubSub, restApp, wsTopic)
|
createHttpApp(setting, pubSub, restApp)
|
||||||
)
|
)
|
||||||
.serve
|
.serve
|
||||||
.drain
|
.drain
|
||||||
@ -76,7 +67,7 @@ object RestServer {
|
|||||||
wsTopic: Topic[F, OutputEvent]
|
wsTopic: Topic[F, OutputEvent]
|
||||||
): Resource[
|
): Resource[
|
||||||
F,
|
F,
|
||||||
(RestApp[F], NaivePubSub[F], Client[F], RInternalSetting)
|
(RestApp[F], NaivePubSub[F], RInternalSetting)
|
||||||
] =
|
] =
|
||||||
for {
|
for {
|
||||||
httpClient <- BlazeClientBuilder[F].resource
|
httpClient <- BlazeClientBuilder[F].resource
|
||||||
@ -92,41 +83,22 @@ object RestServer {
|
|||||||
httpClient
|
httpClient
|
||||||
)(Topics.all.map(_.topic))
|
)(Topics.all.map(_.topic))
|
||||||
restApp <- RestAppImpl.create[F](cfg, store, httpClient, pubSub, wsTopic)
|
restApp <- RestAppImpl.create[F](cfg, store, httpClient, pubSub, wsTopic)
|
||||||
} yield (restApp, pubSub, httpClient, setting)
|
} yield (restApp, pubSub, setting)
|
||||||
|
|
||||||
def createHttpApp[F[_]: Async](
|
def createHttpApp[F[_]: Async](
|
||||||
cfg: Config,
|
|
||||||
internSettings: RInternalSetting,
|
internSettings: RInternalSetting,
|
||||||
httpClient: Client[F],
|
|
||||||
pubSub: NaivePubSub[F],
|
pubSub: NaivePubSub[F],
|
||||||
restApp: RestApp[F],
|
restApp: RestApp[F]
|
||||||
topic: Topic[F, OutputEvent]
|
|
||||||
)(
|
)(
|
||||||
wsB: WebSocketBuilder2[F]
|
wsB: WebSocketBuilder2[F]
|
||||||
) = {
|
) = {
|
||||||
val templates = TemplateRoutes[F](cfg, Templates[F])
|
val internal = Router(
|
||||||
val httpApp = Router(
|
"/" -> redirectTo("/app"),
|
||||||
"/internal" -> InternalHeader(internSettings.internalRouteKey) {
|
"/internal" -> InternalHeader(internSettings.internalRouteKey) {
|
||||||
internalRoutes(pubSub)
|
internalRoutes(pubSub)
|
||||||
},
|
}
|
||||||
"/api/info" -> routes.InfoRoutes(),
|
)
|
||||||
"/api/v1/open/" -> openRoutes(cfg, httpClient, restApp),
|
val httpApp = (internal <+> restApp.routes(wsB)).orNotFound
|
||||||
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
|
||||||
securedRoutes(cfg, restApp, wsB, topic, token)
|
|
||||||
},
|
|
||||||
"/api/v1/admin" -> AdminAuth(cfg.adminEndpoint) {
|
|
||||||
adminRoutes(cfg, restApp)
|
|
||||||
},
|
|
||||||
"/api/v1/share" -> ShareAuth(restApp.backend.share, cfg.auth) { token =>
|
|
||||||
shareRoutes(cfg, restApp, token)
|
|
||||||
},
|
|
||||||
"/api/doc" -> templates.doc,
|
|
||||||
"/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]),
|
|
||||||
"/app" -> EnvMiddleware(templates.app),
|
|
||||||
"/sw.js" -> EnvMiddleware(templates.serviceWorker),
|
|
||||||
"/" -> redirectTo("/app")
|
|
||||||
).orNotFound
|
|
||||||
|
|
||||||
Logger.httpApp(logHeaders = false, logBody = false)(httpApp)
|
Logger.httpApp(logHeaders = false, logBody = false)(httpApp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,88 +107,6 @@ object RestServer {
|
|||||||
"pubsub" -> pubSub.receiveRoute
|
"pubsub" -> pubSub.receiveRoute
|
||||||
)
|
)
|
||||||
|
|
||||||
def securedRoutes[F[_]: Async](
|
|
||||||
cfg: Config,
|
|
||||||
restApp: RestApp[F],
|
|
||||||
wsB: WebSocketBuilder2[F],
|
|
||||||
topic: Topic[F, OutputEvent],
|
|
||||||
token: AuthToken
|
|
||||||
): HttpRoutes[F] =
|
|
||||||
Router(
|
|
||||||
"ws" -> WebSocketRoutes(token, restApp.backend, topic, wsB),
|
|
||||||
"auth" -> LoginRoutes.session(restApp.backend.login, cfg, token),
|
|
||||||
"tag" -> TagRoutes(restApp.backend, token),
|
|
||||||
"equipment" -> EquipmentRoutes(restApp.backend, token),
|
|
||||||
"organization" -> OrganizationRoutes(restApp.backend, token),
|
|
||||||
"person" -> PersonRoutes(restApp.backend, token),
|
|
||||||
"source" -> SourceRoutes(restApp.backend, token),
|
|
||||||
"user/otp" -> TotpRoutes(restApp.backend, cfg, token),
|
|
||||||
"user" -> UserRoutes(restApp.backend, token),
|
|
||||||
"collective" -> CollectiveRoutes(restApp.backend, token),
|
|
||||||
"queue" -> JobQueueRoutes(restApp.backend, token),
|
|
||||||
"item" -> ItemRoutes(cfg, restApp.backend, token),
|
|
||||||
"items" -> ItemMultiRoutes(cfg, restApp.backend, token),
|
|
||||||
"attachment" -> AttachmentRoutes(restApp.backend, token),
|
|
||||||
"attachments" -> AttachmentMultiRoutes(restApp.backend, token),
|
|
||||||
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
|
|
||||||
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
|
|
||||||
"email/send" -> MailSendRoutes(restApp.backend, token),
|
|
||||||
"email/settings" -> MailSettingsRoutes(restApp.backend, token),
|
|
||||||
"email/sent" -> SentMailRoutes(restApp.backend, token),
|
|
||||||
"share" -> ShareRoutes.manage(restApp.backend, token),
|
|
||||||
"usertask/notifydueitems" -> NotifyDueItemsRoutes(cfg, restApp.backend, token),
|
|
||||||
"usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token),
|
|
||||||
"usertask/periodicquery" -> PeriodicQueryRoutes(cfg, restApp.backend, token),
|
|
||||||
"calevent/check" -> CalEventCheckRoutes(),
|
|
||||||
"fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token),
|
|
||||||
"folder" -> FolderRoutes(restApp.backend, token),
|
|
||||||
"customfield" -> CustomFieldRoutes(restApp.backend, token),
|
|
||||||
"clientSettings" -> ClientSettingsRoutes(restApp.backend, token),
|
|
||||||
"notification" -> NotificationRoutes(cfg, restApp.backend, token),
|
|
||||||
"querybookmark" -> BookmarkRoutes(restApp.backend, token)
|
|
||||||
)
|
|
||||||
|
|
||||||
def openRoutes[F[_]: Async](
|
|
||||||
cfg: Config,
|
|
||||||
client: Client[F],
|
|
||||||
restApp: RestApp[F]
|
|
||||||
): HttpRoutes[F] =
|
|
||||||
Router(
|
|
||||||
"auth/openid" -> CodeFlowRoutes(
|
|
||||||
cfg.openIdEnabled,
|
|
||||||
OpenId.handle[F](restApp.backend, cfg),
|
|
||||||
OpenId.codeFlowConfig(cfg),
|
|
||||||
client
|
|
||||||
),
|
|
||||||
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
|
||||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
|
||||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
|
||||||
"checkfile" -> CheckFileRoutes.open(restApp.backend),
|
|
||||||
"integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg),
|
|
||||||
"share" -> ShareRoutes.verify(restApp.backend, cfg)
|
|
||||||
)
|
|
||||||
|
|
||||||
def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
|
|
||||||
Router(
|
|
||||||
"fts" -> FullTextIndexRoutes.admin(cfg, restApp.backend),
|
|
||||||
"user/otp" -> TotpRoutes.admin(restApp.backend),
|
|
||||||
"user" -> UserRoutes.admin(restApp.backend),
|
|
||||||
"info" -> InfoRoutes.admin(cfg),
|
|
||||||
"attachments" -> AttachmentRoutes.admin(restApp.backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
def shareRoutes[F[_]: Async](
|
|
||||||
cfg: Config,
|
|
||||||
restApp: RestApp[F],
|
|
||||||
token: ShareToken
|
|
||||||
): HttpRoutes[F] =
|
|
||||||
Router(
|
|
||||||
"search" -> ShareSearchRoutes(restApp.backend, cfg, token),
|
|
||||||
"attachment" -> ShareAttachmentRoutes(restApp.backend, token),
|
|
||||||
"item" -> ShareItemRoutes(restApp.backend, token),
|
|
||||||
"clientSettings" -> ClientSettingsRoutes.share(restApp.backend, token)
|
|
||||||
)
|
|
||||||
|
|
||||||
def redirectTo[F[_]: Async](path: String): HttpRoutes[F] = {
|
def redirectTo[F[_]: Async](path: String): HttpRoutes[F] = {
|
||||||
val dsl = new Http4sDsl[F] {}
|
val dsl = new Http4sDsl[F] {}
|
||||||
import dsl._
|
import dsl._
|
||||||
|
Reference in New Issue
Block a user