Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add http metrics to all search endpoints by migrating to tapir DEV-2936 #2958

Merged
merged 12 commits into from
Dec 6, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import org.knora.webapi.messages.util.rdf.JsonLDUtil
import org.knora.webapi.messages.util.search.SparqlQueryConstants
import org.knora.webapi.routing.UnsafeZioRun
import org.knora.webapi.routing.v2.ResourcesRouteV2
import org.knora.webapi.routing.v2.SearchRouteV2
import org.knora.webapi.routing.v2.StandoffRouteV2
import org.knora.webapi.routing.v2.ValuesRouteV2
import org.knora.webapi.sharedtestdata.SharedTestDataADM
Expand All @@ -54,13 +53,9 @@ import pekko.http.scaladsl.model.headers.BasicHttpCredentials
*/
class SearchRouteV2R2RSpec extends R2RSpec {
private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance
private val searchPathNew = UnsafeZioRun
private val searchPath = UnsafeZioRun
.runOrThrow(ZIO.serviceWith[SearchApiRoutes](_.routes))
.reduce(_ ~ _)
private val searchPathOld = DSPApiDirectives.handleErrors(appConfig)(
SearchRouteV2(routeData.appConfig.v2.fulltextSearch.searchValueMinLength).makeRoute
)
private val searchPath = searchPathNew ~ searchPathOld

private val resourcePath =
DSPApiDirectives.handleErrors(appConfig)(ResourcesRouteV2(appConfig).makeRoute)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import org.knora.webapi.responders.v2.ResourcesResponseCheckerV2.compareReadReso
import org.knora.webapi.routing.UnsafeZioRun
import org.knora.webapi.sharedtestdata.SharedTestDataADM
import org.knora.webapi.sharedtestdata.SharedTestDataADM.anonymousUser
import org.knora.webapi.slice.resourceinfo.domain.IriConverter
import org.knora.webapi.util.ZioScalaTestUtil.assertFailsWithA

class SearchResponderV2Spec extends CoreSpec {
Expand Down Expand Up @@ -151,60 +152,84 @@ class SearchResponderV2Spec extends CoreSpec {
}

"perform a search by label for incunabula:book that contain 'Narrenschiff'" in {
val result = UnsafeZioRun.runOrThrow(
SearchResponderV2.searchResourcesByLabelV2(
searchValue = "Narrenschiff",
offset = 0,
limitToProject = None,
limitToResourceClass = Some("http://www.knora.org/ontology/0803/incunabula#book".toSmartIri), // internal Iri!
targetSchema = ApiV2Complex,
requestingUser = anonymousUser
)
)

assert(result.resources.size == 3)
val actual = UnsafeZioRun.runOrThrow {
for {
limitToResourceClass <- IriConverter
.asSmartIri("http://www.knora.org/ontology/0803/incunabula#book")
.mapAttempt(_.toOntologySchema(ApiV2Complex))
.map(Some(_))
result <- SearchResponderV2.searchResourcesByLabelV2(
searchValue = "Narrenschiff",
offset = 0,
limitToProject = None,
limitToResourceClass,
targetSchema = ApiV2Complex,
requestingUser = anonymousUser
)
} yield result
}

assert(actual.resources.size == 3)
}

"perform a search by label for incunabula:book that contain 'Das Narrenschiff'" in {
val result = UnsafeZioRun.runOrThrow(
SearchResponderV2.searchResourcesByLabelV2(
searchValue = "Narrenschiff",
offset = 0,
limitToProject = None,
limitToResourceClass = Some("http://www.knora.org/ontology/0803/incunabula#book".toSmartIri), // internal Iri!
targetSchema = ApiV2Complex,
requestingUser = anonymousUser
)
)

assert(result.resources.size == 3)
val actual = UnsafeZioRun.runOrThrow {
for {
limitToResourceClass <- IriConverter
.asSmartIri("http://www.knora.org/ontology/0803/incunabula#book")
.mapAttempt(_.toOntologySchema(ApiV2Complex))
.map(Some(_))
result <- SearchResponderV2.searchResourcesByLabelV2(
searchValue = "Narrenschiff",
offset = 0,
limitToProject = None,
limitToResourceClass,
targetSchema = ApiV2Complex,
requestingUser = anonymousUser
)
} yield result
}

assert(actual.resources.size == 3)
}

"perform a count search query by label for incunabula:book that contain 'Narrenschiff'" in {

val result = UnsafeZioRun.runOrThrow(
SearchResponderV2.searchResourcesByLabelCountV2(
searchValue = "Narrenschiff",
limitToProject = None,
limitToResourceClass = Some("http://www.knora.org/ontology/0803/incunabula#book".toSmartIri)
)
)

assert(result.numberOfResources == 3)
val actual = UnsafeZioRun.runOrThrow {
for {
limitToResourceClass <- IriConverter
.asSmartIri("http://www.knora.org/ontology/0803/incunabula#book")
.mapAttempt(_.toOntologySchema(ApiV2Complex))
.map(Some(_))
result <- SearchResponderV2.searchResourcesByLabelCountV2(
searchValue = "Narrenschiff",
limitToProject = None,
limitToResourceClass
)
} yield result
}

assert(actual.numberOfResources == 3)

}

"perform a a count search query by label for incunabula:book that contain 'Passio sancti Meynrhadi martyris et heremite'" in {

val result = UnsafeZioRun.runOrThrow(
SearchResponderV2.searchResourcesByLabelCountV2(
searchValue = "Passio sancti Meynrhadi martyris et heremite",
limitToProject = None,
limitToResourceClass = Some("http://www.knora.org/ontology/0803/incunabula#book".toSmartIri)
)
)

assert(result.numberOfResources == 1)
val actual = UnsafeZioRun.runOrThrow {
for {
limitToResourceClass <- IriConverter
.asSmartIri("http://www.knora.org/ontology/0803/incunabula#book")
.mapAttempt(_.toOntologySchema(ApiV2Complex))
.map(Some(_))
result <- SearchResponderV2.searchResourcesByLabelCountV2(
searchValue = "Passio sancti Meynrhadi martyris et heremite",
limitToProject = None,
limitToResourceClass
)
} yield result
}

assert(actual.numberOfResources == 1)
}

"search by project and resource class" in {
Expand Down
9 changes: 9 additions & 0 deletions webapi/src/main/scala/dsp/valueobjects/Iri.scala
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@
if (isUserIri(iri)) toSparqlEncodedString(iri)
else None

/**
*/
final case class SimpleIri private (value: String) extends Iri
object SimpleIri {
def from(value: String): Either[String, Iri] =
if (isIri(value)) Right(SimpleIri(value))
else Left(s"Invalid IRI: $value")

Check warning on line 184 in webapi/src/main/scala/dsp/valueobjects/Iri.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/dsp/valueobjects/Iri.scala#L183-L184

Added lines #L183 - L184 were not covered by tests
}

/**
* GroupIri value object.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import org.knora.webapi.RdfMediaTypes
import org.knora.webapi.Rendering
import org.knora.webapi.SchemaOptions
import org.knora.webapi.slice.common.api.ApiV2

/**
* A trait for supported RDF formats.
Expand Down Expand Up @@ -57,11 +56,12 @@

object RdfFormat {

val default: RdfFormat = JsonLD

val values: Seq[RdfFormat] = Seq(JsonLD, Turtle, TriG, RdfXml, NQuads)

def from(mediaType: model.MediaType): RdfFormat = values
def from(mediaType: model.MediaType): Option[RdfFormat] = values
.find(_.mediaType.equalsIgnoreParameters(mediaType))

Check warning on line 64 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L64

Added line #L64 was not covered by tests
.getOrElse(ApiV2.Inputs.defaultRdfFormat)

/**
* Converts a [[MediaType]] to an [[RdfFormat]].
Expand Down Expand Up @@ -326,13 +326,13 @@
jena.riot.RDFDataMgr.write(outputStream, datasetGraph.getDefaultGraph, jena.riot.RDFFormat.TURTLE_FLAT)

case RdfXml =>
jena.riot.RDFDataMgr.write(outputStream, datasetGraph.getDefaultGraph, jena.riot.RDFFormat.RDFXML_PLAIN)

Check warning on line 329 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L329

Added line #L329 was not covered by tests

case TriG =>
jena.riot.RDFDataMgr.write(outputStream, datasetGraph, jena.riot.RDFFormat.TRIG_FLAT)

Check warning on line 332 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L332

Added line #L332 was not covered by tests

case NQuads =>
jena.riot.RDFDataMgr.write(outputStream, datasetGraph, jena.riot.RDFFormat.NQUADS)

Check warning on line 335 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L335

Added line #L335 was not covered by tests
}
}
outputStream.close()
Expand Down Expand Up @@ -366,7 +366,7 @@
)
)
override def start(): Unit = inner.start()
override def base(s: String): Unit = {}

Check warning on line 369 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L369

Added line #L369 was not covered by tests
override def prefix(prefixStr: String, namespace: String): Unit = inner.prefix(prefixStr, namespace)
override def finish(): Unit = inner.finish()
override def triple(triple: jena.graph.Triple): Unit =
Expand Down Expand Up @@ -436,7 +436,7 @@
val jenaRdfFormat: jena.riot.RDFFormat = if (prettyPrint) {
jena.riot.RDFFormat.TURTLE_PRETTY
} else {
jena.riot.RDFFormat.TURTLE_FLAT

Check warning on line 439 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L439

Added line #L439 was not covered by tests
}

jena.riot.RDFDataMgr.write(stringWriter, datasetGraph.getDefaultGraph, jenaRdfFormat)
Expand All @@ -445,22 +445,22 @@
val jenaRdfFormat: jena.riot.RDFFormat = if (prettyPrint) {
jena.riot.RDFFormat.RDFXML_PRETTY
} else {
jena.riot.RDFFormat.RDFXML_PLAIN

Check warning on line 448 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L448

Added line #L448 was not covered by tests
}

jena.riot.RDFDataMgr.write(stringWriter, datasetGraph.getDefaultGraph, jenaRdfFormat)

case TriG =>

Check warning on line 453 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L453

Added line #L453 was not covered by tests
val jenaRdfFormat: jena.riot.RDFFormat = if (prettyPrint) {
jena.riot.RDFFormat.TRIG_PRETTY

Check warning on line 455 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L455

Added line #L455 was not covered by tests
} else {
jena.riot.RDFFormat.TRIG_FLAT

Check warning on line 457 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L457

Added line #L457 was not covered by tests
}

jena.riot.RDFDataMgr.write(stringWriter, datasetGraph, jenaRdfFormat)

Check warning on line 460 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L460

Added line #L460 was not covered by tests

case NQuads =>
jena.riot.RDFDataMgr.write(stringWriter, datasetGraph, jena.riot.RDFFormat.NQUADS)

Check warning on line 463 in webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala#L463

Added line #L463 was not covered by tests
}

stringWriter.toString
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import dsp.errors.BadRequestException
import dsp.errors.GravsearchException
import dsp.errors.InconsistentRepositoryDataException
import dsp.valueobjects.Iri
import org.knora.webapi.*
import org.knora.webapi.config.AppConfig
import org.knora.webapi.core.MessageRelay
Expand Down Expand Up @@ -168,7 +169,7 @@
* @return a [[ResourceCountV2]] representing the resources that have been found.
*/
def searchResourcesByLabelCountV2(
searchValue: IRI,
searchValue: String,
limitToProject: Option[ProjectIri],
limitToResourceClass: Option[SmartIri]
): Task[ResourceCountV2]
Expand All @@ -185,7 +186,7 @@
* @return a [[ReadResourcesSequenceV2]] representing the resources that have been found.
*/
def searchResourcesByLabelV2(
searchValue: IRI,
searchValue: String,
offset: RuntimeFlags,
limitToProject: Option[ProjectIri],
limitToResourceClass: Option[SmartIri],
Expand Down Expand Up @@ -225,12 +226,14 @@
private val sparqlTransformerLive: OntologyInferencer,
private val gravsearchTypeInspectionRunner: GravsearchTypeInspectionRunner,
private val inferenceOptimizationService: InferenceOptimizationService,
implicit private val stringFormatter: StringFormatter,
private val stringFormatter: StringFormatter,
private val iriConverter: IriConverter,
private val constructTransformer: ConstructTransformer
) extends SearchResponderV2
with LazyLogging {

private implicit val sf: StringFormatter = stringFormatter

/**
* Performs a fulltext search and returns the resources count (how many resources match the search criteria),
* without taking into consideration permission checking.
Expand All @@ -250,6 +253,10 @@
limitToStandoffClass: Option[SmartIri]
): Task[ResourceCountV2] =
for {
_ <- ensureIsFulltextSearch(searchValue)
searchValue <- validateSearchString(searchValue)
limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri)
limitToStandoffClass <- ZIO.foreach(limitToStandoffClass)(ensureStandoffClass)
countSparql <- ZIO.attempt(
sparql.v2.txt
.searchFulltext(
Expand Down Expand Up @@ -299,6 +306,10 @@
): Task[ReadResourcesSequenceV2] = {
import org.knora.webapi.messages.util.search.FullTextMainQueryGenerator.FullTextSearchConstants
for {
_ <- ensureIsFulltextSearch(searchValue)
searchValue <- validateSearchString(searchValue)
limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri)
limitToStandoffClass <- ZIO.foreach(limitToStandoffClass)(ensureStandoffClass)
searchSparql <-
ZIO.attempt(
sparql.v2.txt
Expand Down Expand Up @@ -849,14 +860,17 @@
}

override def searchResourcesByLabelCountV2(
searchValue: IRI,
searchValue: String,
limitToProject: Option[ProjectIri],
limitToResourceClass: Option[SmartIri]
): Task[ResourceCountV2] = {
val searchTerm = MatchStringWhileTyping(searchValue).generateLiteralForLuceneIndexWithoutExactSequence
val countSparql =
SearchQueries.selectCountByLabel(searchTerm, limitToProject.map(_.value), limitToResourceClass.map(_.toString))
): Task[ResourceCountV2] =
for {
searchValue <- validateSearchString(searchValue)
_ <- ensureIsFulltextSearch(searchValue)
limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri)
searchTerm = MatchStringWhileTyping(searchValue).generateLiteralForLuceneIndexWithoutExactSequence
countSparql =
SearchQueries.selectCountByLabel(searchTerm, limitToProject.map(_.value), limitToResourceClass.map(_.toString))
countResponse <- triplestore.query(countSparql)

count <- // query response should contain one result with one row with the name "count"
Expand All @@ -870,6 +884,36 @@
.as(countResponse.results.bindings.head.rowMap("count"))

} yield ResourceCountV2(count.toInt)

private def ensureResourceClassIri(resourceClassIri: SmartIri): Task[SmartIri] = {
val errMsg = s"Resource class IRI <$resourceClassIri> is not a valid Knora API v2 entity IRI"
if (resourceClassIri.isKnoraApiV2EntityIri) {
iriConverter.asInternalSmartIri(resourceClassIri).orElseFail(BadRequestException(errMsg))
} else { ZIO.fail(BadRequestException(errMsg)) }

Check warning on line 892 in webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala#L892

Added line #L892 was not covered by tests
}

private def ensureStandoffClass(standoffClassIri: SmartIri): Task[SmartIri] = {
seakayone marked this conversation as resolved.
Show resolved Hide resolved
val errMsg = s"Invalid standoff class IRI: $standoffClassIri"
if (standoffClassIri.isApiV2ComplexSchema) {
iriConverter.asInternalSmartIri(standoffClassIri).orElseFail(BadRequestException(errMsg))
} else { ZIO.fail(BadRequestException(errMsg)) }

Check warning on line 899 in webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala#L899

Added line #L899 was not covered by tests
}

private def ensureIsFulltextSearch(searchStr: String) =
ZIO
.fail(BadRequestException("It looks like you are submitting a Gravsearch request to a full-text search route"))
.when(searchStr.contains(OntologyConstants.KnoraApi.ApiOntologyHostname))

private def validateSearchString(searchStr: String) = {
val searchValueMinLength = appConfig.v2.fulltextSearch.searchValueMinLength
ZIO
.fromOption(Iri.toSparqlEncodedString(searchStr))
.orElseFail(throw BadRequestException(s"Invalid search string: '$searchStr'"))
.filterOrElseWith(_.length >= searchValueMinLength) { it =>
val errorMsg =
s"A search value is expected to have at least length of $searchValueMinLength, but '$it' given of length ${it.length}."
ZIO.fail(BadRequestException(errorMsg))

Check warning on line 915 in webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala

View check run for this annotation

Codecov / codecov/patch

webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala#L915

Added line #L915 was not covered by tests
}
}

override def searchResourcesByLabelV2(
Expand All @@ -882,15 +926,18 @@
): Task[ReadResourcesSequenceV2] = {
val searchLimit = appConfig.v2.resourcesSequence.resultsPerPage
val searchOffset = offset * appConfig.v2.resourcesSequence.resultsPerPage
val searchTerm = MatchStringWhileTyping(searchValue).generateLiteralForLuceneIndexWithoutExactSequence
val searchResourceByLabelSparql = SearchQueries.constructSearchByLabel(
searchTerm,
limitToResourceClass.map(_.toIri),
limitToProject.map(_.value),
searchLimit,
searchOffset
)
for {
searchValue <- validateSearchString(searchValue)
_ <- ensureIsFulltextSearch(searchValue)
limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri)
searchTerm = MatchStringWhileTyping(searchValue).generateLiteralForLuceneIndexWithoutExactSequence
searchResourceByLabelSparql = SearchQueries.constructSearchByLabel(
searchTerm,
limitToResourceClass.map(_.toIri),
limitToProject.map(_.value),
searchLimit,
searchOffset
)
searchResourceByLabelResponse <- triplestore.query(searchResourceByLabelSparql).flatMap(_.asExtended)

// collect the IRIs of main resources returned
Expand Down Expand Up @@ -1012,42 +1059,5 @@
}

object SearchResponderV2Live {
val layer: ZLayer[
AppConfig & TriplestoreService & MessageRelay & ConstructResponseUtilV2 & OntologyCache & StandoffTagUtilV2 &
QueryTraverser & OntologyInferencer & GravsearchTypeInspectionRunner & InferenceOptimizationService &
IriConverter & ConstructTransformer & StringFormatter,
Nothing,
SearchResponderV2Live
] =
ZLayer.fromZIO(
for {
appConfig <- ZIO.service[AppConfig]
triplestoreService <- ZIO.service[TriplestoreService]
messageRelay <- ZIO.service[MessageRelay]
constructResponseUtilV2 <- ZIO.service[ConstructResponseUtilV2]
ontologyCache <- ZIO.service[OntologyCache]
standoffTagUtilV2 <- ZIO.service[StandoffTagUtilV2]
queryTraverser <- ZIO.service[QueryTraverser]
sparqlTransformerLive <- ZIO.service[OntologyInferencer]
stringFormatter <- ZIO.service[StringFormatter]
typeInspectionRunner <- ZIO.service[GravsearchTypeInspectionRunner]
inferenceOptimizationService <- ZIO.service[InferenceOptimizationService]
iriConverter <- ZIO.service[IriConverter]
constructTransformer <- ZIO.service[ConstructTransformer]
} yield new SearchResponderV2Live(
appConfig,
triplestoreService,
messageRelay,
constructResponseUtilV2,
ontologyCache,
standoffTagUtilV2,
queryTraverser,
sparqlTransformerLive,
typeInspectionRunner,
inferenceOptimizationService,
stringFormatter,
iriConverter,
constructTransformer
)
)
val layer = ZLayer.derive[SearchResponderV2Live]
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ private final case class ApiRoutesImpl(
PermissionsRouteADM(routeData, runtime).makeRoute ~
RejectingRoute(appConfig, runtime).makeRoute ~
ResourcesRouteV2(appConfig).makeRoute ~
SearchRouteV2(appConfig.v2.fulltextSearch.searchValueMinLength).makeRoute ~
StandoffRouteV2().makeRoute ~
StoreRouteADM(routeData, runtime).makeRoute ~
UsersRouteADM().makeRoute ~
Expand Down
Loading
Loading