Implement search by query in endpoints

This commit is contained in:
Eike Kettner 2021-03-01 12:37:25 +01:00
parent 698ff58aa3
commit dadab0d308
6 changed files with 249 additions and 73 deletions

View File

@ -37,6 +37,7 @@ trait BackendApp[F[_]] {
def userTask: OUserTask[F] def userTask: OUserTask[F]
def folder: OFolder[F] def folder: OFolder[F]
def customFields: OCustomFields[F] def customFields: OCustomFields[F]
def simpleSearch: OSimpleSearch[F]
} }
object BackendApp { object BackendApp {
@ -71,6 +72,7 @@ object BackendApp {
userTaskImpl <- OUserTask(utStore, queue, joexImpl) userTaskImpl <- OUserTask(utStore, queue, joexImpl)
folderImpl <- OFolder(store) folderImpl <- OFolder(store)
customFieldsImpl <- OCustomFields(store) customFieldsImpl <- OCustomFields(store)
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
} yield new BackendApp[F] { } yield new BackendApp[F] {
val login = loginImpl val login = loginImpl
val signup = signupImpl val signup = signupImpl
@ -90,6 +92,7 @@ object BackendApp {
val userTask = userTaskImpl val userTask = userTaskImpl
val folder = folderImpl val folder = folderImpl
val customFields = customFieldsImpl val customFields = customFieldsImpl
val simpleSearch = simpleSearchImpl
} }
def apply[F[_]: ConcurrentEffect: ContextShift]( def apply[F[_]: ConcurrentEffect: ContextShift](

View File

@ -1,29 +1,28 @@
package docspell.backend.ops package docspell.backend.ops
import cats.effect.Sync
import cats.implicits._ import cats.implicits._
import docspell.backend.ops.OSimpleSearch._
import docspell.common._ import docspell.common._
import docspell.query.{ItemQueryParser, ParseFailure}
import docspell.store.qb.Batch import docspell.store.qb.Batch
import docspell.store.queries.Query import docspell.store.queries.Query
import docspell.query.{ItemQueryParser, ParseFailure}
import OSimpleSearch._
import docspell.store.queries.SearchSummary import docspell.store.queries.SearchSummary
import cats.effect.Sync
/** A "porcelain" api on top of OFulltext and OItemSearch. */ /** A "porcelain" api on top of OFulltext and OItemSearch. */
trait OSimpleSearch[F[_]] { trait OSimpleSearch[F[_]] {
def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items] def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items]
def searchSummary( def searchSummary(
settings: Settings useFTS: Boolean
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] )(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
def searchByString( def searchByString(
settings: Settings settings: Settings
)(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]]
def searchSummaryByString( def searchSummaryByString(
settings: Settings useFTS: Boolean
)(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]]
} }
@ -36,12 +35,6 @@ object OSimpleSearch {
resolveDetails: Boolean, resolveDetails: Boolean,
maxNoteLen: Int maxNoteLen: Int
) )
object Settings {
def plain(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings =
Settings(batch, useFulltext, false, maxNoteLen)
def detailed(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings =
Settings(batch, useFulltext, true, maxNoteLen)
}
sealed trait Items { sealed trait Items {
def fold[A]( def fold[A](
@ -118,18 +111,18 @@ object OSimpleSearch {
.map(search(settings)(_, None)) //TODO resolve content:xyz expressions .map(search(settings)(_, None)) //TODO resolve content:xyz expressions
def searchSummaryByString( def searchSummaryByString(
settings: Settings useFTS: Boolean
)(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] = )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] =
ItemQueryParser ItemQueryParser
.parse(q.query) .parse(q.query)
.map(iq => Query(fix, Query.QueryExpr(iq))) .map(iq => Query(fix, Query.QueryExpr(iq)))
.map(searchSummary(settings)(_, None)) //TODO resolve content:xyz expressions .map(searchSummary(useFTS)(_, None)) //TODO resolve content:xyz expressions
def searchSummary( def searchSummary(
settings: Settings useFTS: Boolean
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
fulltextQuery match { fulltextQuery match {
case Some(ftq) if settings.useFTS => case Some(ftq) if useFTS =>
if (q.isEmpty) if (q.isEmpty)
fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq)) fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq))
else else

View File

@ -1310,16 +1310,16 @@ paths:
$ref: "#/components/schemas/BasicResult" $ref: "#/components/schemas/BasicResult"
/sec/item/search: /sec/item/searchForm:
post: post:
tags: [ Item ] tags: [ Item Search ]
summary: Search for items. summary: Search for items.
description: | description: |
Search for items given a search form. The results are grouped Search for items given a search form. The results are grouped
by month and are sorted by item date (newest first). Tags and by month and are sorted by item date (newest first). Tags and
attachments are *not* resolved. The results will always attachments are *not* resolved. The results will always
contain an empty list for item tags and attachments. Use contain an empty list for item tags and attachments. Use
`/searchWithTags` to also retrieve all tags and a list of `/searchFormWithTags` to also retrieve all tags and a list of
attachments of an item. attachments of an item.
The `fulltext` field can be used to restrict the results by The `fulltext` field can be used to restrict the results by
@ -1328,6 +1328,8 @@ paths:
The customfields used in the search query are allowed to be The customfields used in the search query are allowed to be
specified by either field id or field name. The values may specified by either field id or field name. The values may
contain the wildcard `*` at beginning or end. contain the wildcard `*` at beginning or end.
**NOTE** This is deprecated in favor for using a search query.
security: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:
@ -1342,9 +1344,9 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ItemLightList" $ref: "#/components/schemas/ItemLightList"
/sec/item/searchWithTags: /sec/item/searchFormWithTags:
post: post:
tags: [ Item ] tags: [ Item Search ]
summary: Search for items. summary: Search for items.
description: | description: |
Search for items given a search form. The results are grouped Search for items given a search form. The results are grouped
@ -1355,6 +1357,8 @@ paths:
The `fulltext` field can be used to restrict the results by The `fulltext` field can be used to restrict the results by
using full-text search in the documents contents. using full-text search in the documents contents.
**NOTE** This is deprecated in favor for using search query.
security: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:
@ -1369,9 +1373,60 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ItemLightList" $ref: "#/components/schemas/ItemLightList"
/sec/item/search:
get:
tags: [ Item Search ]
summary: Search for items.
description: |
Search for items given a search query. The results are grouped
by month and are sorted by item date (newest first). Tags and
attachments are *not* resolved. The results will always
contain an empty list for item tags and attachments. Set
`withDetails` to `true` for retrieving all tags and a list of
attachments of an item.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
- $ref: "#/components/parameters/withDetails"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLightList"
post:
tags: [ Item Search ]
summary: Search for items.
description: |
Search for items given a search query. The results are grouped
by month and are sorted by item date (newest first). Tags and
attachments are *not* resolved. The results will always
contain an empty list for item tags and attachments. Use
`withDetails` to also retrieve all tags and a list of
attachments of an item.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLightList"
/sec/item/searchIndex: /sec/item/searchIndex:
post: post:
tags: [ Item ] tags: [ Item Search ]
summary: Search for items using full-text search only. summary: Search for items using full-text search only.
description: | description: |
Search for items by only using the full-text search index. Search for items by only using the full-text search index.
@ -1391,7 +1446,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ItemFtsSearch" $ref: "#/components/schemas/ItemQuery"
responses: responses:
200: 200:
description: Ok description: Ok
@ -1400,12 +1455,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/ItemLightList" $ref: "#/components/schemas/ItemLightList"
/sec/item/searchStats: /sec/item/searchFormStats:
post: post:
tags: [ Item ] tags: [ Item Search ]
summary: Get basic statistics about the data of a search. summary: Get basic statistics about the data of a search.
description: | description: |
Takes a search query and returns a summary about the results. Takes a search query and returns a summary about the results.
**NOTE** This is deprecated in favor of using a search query.
security: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:
@ -1420,6 +1477,44 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/SearchStats" $ref: "#/components/schemas/SearchStats"
/sec/item/searchStats:
post:
tags: [ Item Search ]
summary: Get basic statistics about search results.
description: |
Instead of returning the results of a query, uses it to return
a summary.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/SearchStats"
get:
tags: [ Item Search ]
summary: Get basic statistics about search results.
description: |
Instead of returning the results of a query, uses it to return
a summary.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/q"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/SearchStats"
/sec/item/{id}: /sec/item/{id}:
get: get:
@ -3777,22 +3872,12 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/IdName" $ref: "#/components/schemas/IdName"
FolderMember: ItemQuery:
description: | description: |
Information to add or remove a folder member. Query description for a search. Is used for fulltext-only
required: searches and combined searches.
- userId
properties:
userId:
type: string
format: ident
ItemFtsSearch:
description: |
Query description for a full-text only search.
required: required:
- query - query
- offset
- limit
properties: properties:
offset: offset:
type: integer type: integer
@ -3804,6 +3889,9 @@ components:
The maximum number of results to return. Note that this The maximum number of results to return. Note that this
limit is a soft limit, there is some hard limit on the limit is a soft limit, there is some hard limit on the
server, too. server, too.
withDetails:
type: boolean
default: false
query: query:
type: string type: string
description: | description: |
@ -5547,6 +5635,26 @@ components:
required: false required: false
schema: schema:
type: string type: string
limit:
name: limit
in: query
description: A limit for a search query
schema:
type: integer
format: int32
offset:
name: offset
in: query
description: A offset into the results for a search query
schema:
type: integer
format: int32
withDetails:
name: withDetails
in: query
description: Whether to return details to each item.
schema:
type: boolean
name: name:
name: name name: name
in: path in: path

View File

@ -28,6 +28,7 @@ object QueryParam {
object Query extends OptionalQueryParamDecoderMatcher[String]("q") object Query extends OptionalQueryParamDecoderMatcher[String]("q")
object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit")
object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset")
object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails")
object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback") object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback")
} }

View File

@ -10,9 +10,9 @@ import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
import docspell.backend.ops.OFulltext import docspell.backend.ops.OFulltext
import docspell.backend.ops.OItemSearch.{Batch, Query} import docspell.backend.ops.OItemSearch.{Batch, Query}
import docspell.backend.ops.OSimpleSearch
import docspell.common._ import docspell.common._
import docspell.common.syntax.all._ import docspell.common.syntax.all._
import docspell.query.ItemQueryParser
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.Config import docspell.restserver.Config
import docspell.restserver.conv.Conversions import docspell.restserver.conv.Conversions
@ -49,30 +49,96 @@ object ItemRoutes {
case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset( case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset(
offset offset
) => ) :? QP.WithDetails(detailFlag) =>
val query = val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize))
ItemQueryParser.parse(q.getOrElse("")) match { .restrictLimitTo(cfg.maxItemPageSize)
case Right(q) => val itemQuery = ItemQueryString(q)
Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q))) val settings = OSimpleSearch.Settings(
case Left(err) => batch,
Left(err) cfg.fullTextSearch.enabled,
} detailFlag.getOrElse(false),
val li = limit.getOrElse(cfg.maxItemPageSize) cfg.maxNoteLength
val of = offset.getOrElse(0)
query match {
case Left(err) =>
BadRequest(BasicResult(false, err.render))
case Right(sq) =>
for {
items <- backend.itemSearch.findItems(cfg.maxNoteLength)(
sq,
Batch(of, li).restrictLimitTo(cfg.maxItemPageSize)
) )
ok <- Ok(Conversions.mkItemList(items)) val fixQuery = Query.Fix(user.account, None, None)
} yield ok backend.simpleSearch.searchByString(settings)(fixQuery, itemQuery) match {
case Right(results) =>
val items = results.map(
_.fold(
Conversions.mkItemListFts,
Conversions.mkItemListWithTagsFts,
Conversions.mkItemList,
Conversions.mkItemListWithTags
)
)
Ok(items)
case Left(fail) =>
BadRequest(BasicResult(false, fail.render))
}
case GET -> Root / "searchStats" :? QP.Query(q) =>
val itemQuery = ItemQueryString(q)
val fixQuery = Query.Fix(user.account, None, None)
backend.simpleSearch
.searchSummaryByString(cfg.fullTextSearch.enabled)(fixQuery, itemQuery) match {
case Right(summary) =>
summary.flatMap(s => Ok(Conversions.mkSearchStats(s)))
case Left(fail) =>
BadRequest(BasicResult(false, fail.render))
} }
case req @ POST -> Root / "search" => case req @ POST -> Root / "search" =>
for {
userQuery <- req.as[ItemQuery]
batch = Batch(
userQuery.offset.getOrElse(0),
userQuery.limit.getOrElse(cfg.maxItemPageSize)
).restrictLimitTo(
cfg.maxItemPageSize
)
itemQuery = ItemQueryString(userQuery.query)
settings = OSimpleSearch.Settings(
batch,
cfg.fullTextSearch.enabled,
userQuery.withDetails.getOrElse(false),
cfg.maxNoteLength
)
fixQuery = Query.Fix(user.account, None, None)
resp <- backend.simpleSearch
.searchByString(settings)(fixQuery, itemQuery) match {
case Right(results) =>
val items = results.map(
_.fold(
Conversions.mkItemListFts,
Conversions.mkItemListWithTagsFts,
Conversions.mkItemList,
Conversions.mkItemListWithTags
)
)
Ok(items)
case Left(fail) =>
BadRequest(BasicResult(false, fail.render))
}
} yield resp
case req @ POST -> Root / "searchStats" =>
for {
userQuery <- req.as[ItemQuery]
itemQuery = ItemQueryString(userQuery.query)
fixQuery = Query.Fix(user.account, None, None)
resp <- backend.simpleSearch
.searchSummaryByString(cfg.fullTextSearch.enabled)(
fixQuery,
itemQuery
) match {
case Right(summary) =>
summary.flatMap(s => Ok(Conversions.mkSearchStats(s)))
case Left(fail) =>
BadRequest(BasicResult(false, fail.render))
}
} yield resp
//DEPRECATED
case req @ POST -> Root / "searchForm" =>
for { for {
mask <- req.as[ItemSearch] mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask") _ <- logger.ftrace(s"Got search mask: $mask")
@ -111,7 +177,8 @@ object ItemRoutes {
} }
} yield resp } yield resp
case req @ POST -> Root / "searchWithTags" => //DEPRECATED
case req @ POST -> Root / "searchFormWithTags" =>
for { for {
mask <- req.as[ItemSearch] mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask") _ <- logger.ftrace(s"Got search mask: $mask")
@ -151,7 +218,7 @@ object ItemRoutes {
case req @ POST -> Root / "searchIndex" => case req @ POST -> Root / "searchIndex" =>
for { for {
mask <- req.as[ItemFtsSearch] mask <- req.as[ItemQuery]
resp <- mask.query match { resp <- mask.query match {
case q if q.length > 1 => case q if q.length > 1 =>
val ftsIn = OFulltext.FtsInput(q) val ftsIn = OFulltext.FtsInput(q)
@ -159,7 +226,10 @@ object ItemRoutes {
items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)( items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)(
ftsIn, ftsIn,
user.account, user.account,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) Batch(
mask.offset.getOrElse(0),
mask.limit.getOrElse(cfg.maxItemPageSize)
).restrictLimitTo(cfg.maxItemPageSize)
) )
ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items)) ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items))
} yield ok } yield ok
@ -169,7 +239,8 @@ object ItemRoutes {
} }
} yield resp } yield resp
case req @ POST -> Root / "searchStats" => //DEPRECATED
case req @ POST -> Root / "searchFormStats" =>
for { for {
mask <- req.as[ItemSearch] mask <- req.as[ItemSearch]
query = Conversions.mkQuery(mask, user.account) query = Conversions.mkQuery(mask, user.account)
@ -479,12 +550,12 @@ object ItemRoutes {
private val itemSearchMonoid: Monoid[ItemSearch] = private val itemSearchMonoid: Monoid[ItemSearch] =
cats.derived.semiauto.monoid cats.derived.semiauto.monoid
def unapply(m: ItemSearch): Option[ItemFtsSearch] = def unapply(m: ItemSearch): Option[ItemQuery] =
m.fullText match { m.fullText match {
case Some(fq) => case Some(fq) =>
val me = m.copy(fullText = None, offset = 0, limit = 0) val me = m.copy(fullText = None, offset = 0, limit = 0)
if (itemSearchMonoid.empty == me) if (itemSearchMonoid.empty == me)
Some(ItemFtsSearch(m.offset, m.limit, fq)) Some(ItemQuery(m.offset.some, m.limit.some, Some(false), fq))
else None else None
case _ => case _ =>
None None

View File

@ -156,10 +156,10 @@ import Api.Model.ImapSettings exposing (ImapSettings)
import Api.Model.ImapSettingsList exposing (ImapSettingsList) import Api.Model.ImapSettingsList exposing (ImapSettingsList)
import Api.Model.InviteResult exposing (InviteResult) import Api.Model.InviteResult exposing (InviteResult)
import Api.Model.ItemDetail exposing (ItemDetail) import Api.Model.ItemDetail exposing (ItemDetail)
import Api.Model.ItemFtsSearch exposing (ItemFtsSearch)
import Api.Model.ItemInsights exposing (ItemInsights) import Api.Model.ItemInsights exposing (ItemInsights)
import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemLightList exposing (ItemLightList)
import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemProposals exposing (ItemProposals)
import Api.Model.ItemQuery exposing (ItemQuery)
import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemSearch exposing (ItemSearch)
import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta)
import Api.Model.ItemsAndDate exposing (ItemsAndDate) import Api.Model.ItemsAndDate exposing (ItemsAndDate)
@ -1684,14 +1684,14 @@ moveAttachmentBefore flags itemId data receive =
itemIndexSearch : itemIndexSearch :
Flags Flags
-> ItemFtsSearch -> ItemQuery
-> (Result Http.Error ItemLightList -> msg) -> (Result Http.Error ItemLightList -> msg)
-> Cmd msg -> Cmd msg
itemIndexSearch flags query receive = itemIndexSearch flags query receive =
Http2.authPost Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/item/searchIndex" { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchIndex"
, account = getAccount flags , account = getAccount flags
, body = Http.jsonBody (Api.Model.ItemFtsSearch.encode query) , body = Http.jsonBody (Api.Model.ItemQuery.encode query)
, expect = Http.expectJson receive Api.Model.ItemLightList.decoder , expect = Http.expectJson receive Api.Model.ItemLightList.decoder
} }
@ -1699,7 +1699,7 @@ itemIndexSearch flags query receive =
itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
itemSearch flags search receive = itemSearch flags search receive =
Http2.authPost Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/item/searchWithTags" { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormWithTags"
, account = getAccount flags , account = getAccount flags
, body = Http.jsonBody (Api.Model.ItemSearch.encode search) , body = Http.jsonBody (Api.Model.ItemSearch.encode search)
, expect = Http.expectJson receive Api.Model.ItemLightList.decoder , expect = Http.expectJson receive Api.Model.ItemLightList.decoder
@ -1709,7 +1709,7 @@ itemSearch flags search receive =
itemSearchStats : Flags -> ItemSearch -> (Result Http.Error SearchStats -> msg) -> Cmd msg itemSearchStats : Flags -> ItemSearch -> (Result Http.Error SearchStats -> msg) -> Cmd msg
itemSearchStats flags search receive = itemSearchStats flags search receive =
Http2.authPost Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/item/searchStats" { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormStats"
, account = getAccount flags , account = getAccount flags
, body = Http.jsonBody (Api.Model.ItemSearch.encode search) , body = Http.jsonBody (Api.Model.ItemSearch.encode search)
, expect = Http.expectJson receive Api.Model.SearchStats.decoder , expect = Http.expectJson receive Api.Model.SearchStats.decoder