Skip to content

Commit

Permalink
Create openAPI schema for parts of the REST API using Swagger (#5693)
Browse files Browse the repository at this point in the history
* [WIP] add swagger api docs

* annotate some more routes, add base path

* pretty

* add dataset route to swagger api doc, remove some not-needed ones

* download route

* more annotations, hide unwanted

* changelog

* add title to swagger json

Co-authored-by: Jonathan Striebel <jstriebel@users.noreply.github.com>
  • Loading branch information
fm3 and jstriebel committed Sep 2, 2021
1 parent 4ecbd45 commit 1ec60f8
Show file tree
Hide file tree
Showing 14 changed files with 247 additions and 55 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/21.08.0...HEAD)

### Added
-
- Added a rudimentary version of openAPI docs for some routes. Available at `/swagger.json`. [#5693](https://github.com/scalableminds/webknossos/pull/5693)

### Changed
- By default, if data is missing in one magnification, higher magnifications are used for rendering. This setting can be controlled via the left sidebar under "Render Missing Data Black". [#5703](https://github.com/scalableminds/webknossos/pull/5703)
Expand Down
2 changes: 1 addition & 1 deletion app/RequestHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class RequestHandler @Inject()(webCommands: WebCommands,
with LazyLogging {

override def routeRequest(request: RequestHeader): Option[Handler] =
if (request.uri.matches("^(/api/|/data/|/tracings/).*$")) {
if (request.uri.matches("^(/api/|/data/|/tracings/|/swagger).*$")) {
super.routeRequest(request)
} else if (request.uri.matches("^(/assets/).*$")) {
val path = request.path.replaceFirst("^(/assets/)", "")
Expand Down
78 changes: 55 additions & 23 deletions app/controllers/AnnotationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContex
import com.scalableminds.util.tools.{Fox, FoxImplicits}
import com.scalableminds.webknossos.tracingstore.tracings.TracingType
import com.scalableminds.webknossos.tracingstore.tracings.volume.ResolutionRestrictions
import io.swagger.annotations.{Api, ApiOperation, ApiParam, ApiResponse, ApiResponses}
import models.annotation.AnnotationState.Cancelled
import models.annotation._
import models.binary.{DataSetDAO, DataSetService}
Expand Down Expand Up @@ -34,6 +35,7 @@ object CreateExplorationalParameters {
implicit val jsonFormat: OFormat[CreateExplorationalParameters] = Json.format[CreateExplorationalParameters]
}

@Api
class AnnotationController @Inject()(
annotationDAO: AnnotationDAO,
taskDAO: TaskDAO,
Expand All @@ -59,31 +61,44 @@ class AnnotationController @Inject()(
implicit val timeout: Timeout = Timeout(5 seconds)
private val taskReopenAllowed = (conf.Features.taskReopenAllowed + (10 seconds)).toMillis

def info(typ: String, id: String, timestamp: Long): Action[AnyContent] = sil.UserAwareAction.async {
implicit request =>
log() {
val notFoundMessage =
if (request.identity.isEmpty) "annotation.notFound.considerLoggingIn" else "annotation.notFound"
for {
annotation <- provider.provideAnnotation(typ, id, request.identity) ?~> notFoundMessage ~> NOT_FOUND
_ <- bool2Fox(annotation.state != Cancelled) ?~> "annotation.cancelled"
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
_ <- restrictions.allowAccess(request.identity) ?~> "notAllowed" ~> FORBIDDEN
typedTyp <- AnnotationType.fromString(typ).toFox ?~> "annotationType.notFound" ~> NOT_FOUND
js <- annotationService
.publicWrites(annotation, request.identity, Some(restrictions)) ?~> "annotation.write.failed"
_ <- Fox.runOptional(request.identity) { user =>
if (typedTyp == AnnotationType.Task || typedTyp == AnnotationType.Explorational) {
timeSpanService.logUserInteraction(timestamp, user, annotation) // log time when a user starts working
} else Fox.successful(())
}
_ = request.identity.map { user =>
analyticsService.track(OpenAnnotationEvent(user, annotation))
}
} yield Ok(js)
}
@ApiOperation(value = "Information about an annotation")
@ApiResponses(
Array(new ApiResponse(code = 200, message = "JSON object containing information about this annotation."),
new ApiResponse(code = 400, message = badRequestLabel)))
def info(
@ApiParam(value =
"Type of the annotation, one of Task, Explorational, CompoundTask, CompoundProject, CompoundTaskType",
example = "Explorational") typ: String,
@ApiParam(
value =
"For Task and Explorational annotations, id is an annotation id. For CompoundTask, id is a task id. For CompoundProject, id is a project id. For CompoundTaskType, id is a task type id")
id: String,
@ApiParam(value = "Timestamp in milliseconds (time at which the request is sent)") timestamp: Long)
: Action[AnyContent] = sil.UserAwareAction.async { implicit request =>
log() {
val notFoundMessage =
if (request.identity.isEmpty) "annotation.notFound.considerLoggingIn" else "annotation.notFound"
for {
annotation <- provider.provideAnnotation(typ, id, request.identity) ?~> notFoundMessage ~> NOT_FOUND
_ <- bool2Fox(annotation.state != Cancelled) ?~> "annotation.cancelled"
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
_ <- restrictions.allowAccess(request.identity) ?~> "notAllowed" ~> FORBIDDEN
typedTyp <- AnnotationType.fromString(typ).toFox ?~> "annotationType.notFound" ~> NOT_FOUND
js <- annotationService
.publicWrites(annotation, request.identity, Some(restrictions)) ?~> "annotation.write.failed"
_ <- Fox.runOptional(request.identity) { user =>
if (typedTyp == AnnotationType.Task || typedTyp == AnnotationType.Explorational) {
timeSpanService.logUserInteraction(timestamp, user, annotation) // log time when a user starts working
} else Fox.successful(())
}
_ = request.identity.map { user =>
analyticsService.track(OpenAnnotationEvent(user, annotation))
}
} yield Ok(js)
}
}

@ApiOperation(hidden = true, value = "")
def merge(typ: String, id: String, mergedTyp: String, mergedId: String): Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
for {
Expand All @@ -97,6 +112,7 @@ class AnnotationController @Inject()(
} yield JsonOk(js, Messages("annotation.merge.success"))
}

@ApiOperation(hidden = true, value = "")
def loggedTime(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
annotation <- provider.provideAnnotation(typ, id, request.identity) ~> NOT_FOUND
Expand All @@ -115,6 +131,7 @@ class AnnotationController @Inject()(
}
}

@ApiOperation(hidden = true, value = "")
def reset(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
annotation <- provider.provideAnnotation(typ, id, request.identity) ~> NOT_FOUND
Expand All @@ -125,6 +142,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json, Messages("annotation.reset.success"))
}

@ApiOperation(hidden = true, value = "")
def reopen(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
def isReopenAllowed(user: User, annotation: Annotation) =
for {
Expand All @@ -145,6 +163,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json, Messages("annotation.reopened"))
}

@ApiOperation(hidden = true, value = "")
def createExplorational(organizationName: String, dataSetName: String): Action[CreateExplorationalParameters] =
sil.SecuredAction.async(validateJson[CreateExplorationalParameters]) { implicit request =>
for {
Expand All @@ -168,6 +187,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json)
}

@ApiOperation(hidden = true, value = "")
def makeHybrid(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(AnnotationType.Explorational.toString == typ) ?~> "annotation.makeHybrid.explorationalsOnly"
Expand All @@ -179,6 +199,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json)
}

@ApiOperation(hidden = true, value = "")
def downsample(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(AnnotationType.Explorational.toString == typ) ?~> "annotation.downsample.explorationalsOnly"
Expand All @@ -189,6 +210,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json)
}

@ApiOperation(hidden = true, value = "")
def unlinkFallback(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(AnnotationType.Explorational.toString == typ) ?~> "annotation.unlinkFallback.explorationalsOnly"
Expand Down Expand Up @@ -217,6 +239,7 @@ class AnnotationController @Inject()(
_ <- timeSpanService.logUserInteraction(timestamp, issuingUser, annotation) // log time on tracing end
} yield (updated, message)

@ApiOperation(hidden = true, value = "")
def finish(typ: String, id: String, timestamp: Long): Action[AnyContent] = sil.SecuredAction.async {
implicit request =>
log() {
Expand All @@ -228,6 +251,7 @@ class AnnotationController @Inject()(
}
}

@ApiOperation(hidden = true, value = "")
def finishAll(typ: String, timestamp: Long): Action[JsValue] = sil.SecuredAction.async(parse.json) {
implicit request =>
log() {
Expand All @@ -243,6 +267,7 @@ class AnnotationController @Inject()(
}
}

@ApiOperation(hidden = true, value = "")
def editAnnotation(typ: String, id: String): Action[JsValue] = sil.SecuredAction.async(parse.json) {
implicit request =>
for {
Expand All @@ -264,6 +289,7 @@ class AnnotationController @Inject()(
} yield JsonOk(Messages("annotation.edit.success"))
}

@ApiOperation(hidden = true, value = "")
def annotationsForTask(taskId: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
taskIdValidated <- ObjectId.parse(taskId)
Expand All @@ -275,6 +301,7 @@ class AnnotationController @Inject()(
} yield Ok(JsArray(jsons.flatten))
}

@ApiOperation(hidden = true, value = "")
def cancel(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
def tryToCancel(annotation: Annotation) =
annotation match {
Expand All @@ -295,6 +322,7 @@ class AnnotationController @Inject()(
} yield result
}

@ApiOperation(hidden = true, value = "")
def transfer(typ: String, id: String): Action[JsValue] = sil.SecuredAction.async(parse.json) { implicit request =>
for {
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
Expand All @@ -306,6 +334,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json)
}

@ApiOperation(hidden = true, value = "")
def duplicate(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
annotation <- provider.provideAnnotation(typ, id, request.identity) ~> NOT_FOUND
Expand All @@ -316,6 +345,7 @@ class AnnotationController @Inject()(
} yield JsonOk(json)
}

@ApiOperation(hidden = true, value = "")
def sharedAnnotations: Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
userTeams <- userService.teamIdsFor(request.identity._id)
Expand All @@ -324,6 +354,7 @@ class AnnotationController @Inject()(
} yield Ok(Json.toJson(json))
}

@ApiOperation(hidden = true, value = "")
def getSharedTeams(typ: String, id: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
annotation <- provider.provideAnnotation(typ, id, request.identity)
Expand All @@ -333,6 +364,7 @@ class AnnotationController @Inject()(
} yield Ok(Json.toJson(json))
}

@ApiOperation(hidden = true, value = "")
def updateSharedTeams(typ: String, id: String): Action[JsValue] = sil.SecuredAction.async(parse.json) {
implicit request =>
withJsonBodyAs[List[String]] { teams =>
Expand Down
45 changes: 38 additions & 7 deletions app/controllers/AnnotationIOController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import com.mohiva.play.silhouette.api.Silhouette
import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.io.{NamedEnumeratorStream, ZipIO}
import com.scalableminds.util.tools.{Fox, FoxImplicits, TextUtils}
import com.scalableminds.webknossos.datastore.models.datasource.{AbstractSegmentationLayer, SegmentationLayer}
import com.scalableminds.webknossos.datastore.SkeletonTracing.{SkeletonTracing, SkeletonTracingOpt, SkeletonTracings}
import com.scalableminds.webknossos.datastore.VolumeTracing.{VolumeTracing, VolumeTracingOpt, VolumeTracings}
import com.scalableminds.webknossos.datastore.helpers.ProtoGeometryImplicits
import com.scalableminds.webknossos.tracingstore.tracings.volume.VolumeTracingDefaults
import com.scalableminds.webknossos.datastore.models.datasource.{AbstractSegmentationLayer, SegmentationLayer}
import com.scalableminds.webknossos.tracingstore.tracings.TracingType
import com.scalableminds.webknossos.tracingstore.tracings.volume.VolumeTracingDefaults
import com.typesafe.scalalogging.LazyLogging
import io.swagger.annotations._
import javax.inject.Inject
import models.analytics.{AnalyticsService, DownloadAnnotationEvent, UploadAnnotationEvent}
import models.annotation.AnnotationState._
Expand All @@ -39,6 +40,7 @@ import utils.ObjectId

import scala.concurrent.{ExecutionContext, Future}

@Api
class AnnotationIOController @Inject()(
nmlWriter: NmlWriter,
annotationDAO: AnnotationDAO,
Expand All @@ -61,6 +63,22 @@ class AnnotationIOController @Inject()(
with LazyLogging {
implicit val actorSystem: ActorSystem = ActorSystem()

@ApiOperation(
value =
"""Upload NML(s) or ZIP(s) of NML(s) to create a new explorative annotation.
Expects:
- As file attachment: any number of NML files or ZIP files containing NMLs, optionally with at most one volume data ZIP referenced from an NML in a ZIP
- As form parameter: createGroupForEachFile [String] should be one of "true" or "false"
- If "true": in merged annotation, create tree group wrapping the trees of each file
- If "false": in merged annotation, rename trees with the respective file name as prefix""")
@ApiResponses(
Array(
new ApiResponse(
code = 200,
message =
"JSON object containing annotation information about the newly created annotation, including the assigned id"),
new ApiResponse(code = 400, message = badRequestLabel)
))
def upload: Action[MultipartFormData[TemporaryFile]] = sil.SecuredAction.async(parse.multipartFormData) {
implicit request =>
log() {
Expand Down Expand Up @@ -201,11 +219,24 @@ class AnnotationIOController @Inject()(
)
}

def download(typ: String,
id: String,
skeletonVersion: Option[Long],
volumeVersion: Option[Long],
skipVolumeData: Option[Boolean]): Action[AnyContent] =
@ApiOperation(value = "Download an annotation as NML/ZIP")
@ApiResponses(
Array(
new ApiResponse(code = 200,
message = "NML or Zip file containing skeleton and/or volume data of this annotation."),
new ApiResponse(code = 400, message = badRequestLabel)
))
def download(
@ApiParam(value =
"Type of the annotation, one of Task, Explorational, CompoundTask, CompoundProject, CompoundTaskType",
example = "Explorational") typ: String,
@ApiParam(
value =
"For Task and Explorational annotations, id is an annotation id. For CompoundTask, id is a task id. For CompoundProject, id is a project id. For CompoundTaskType, id is a task type id")
id: String,
skeletonVersion: Option[Long],
volumeVersion: Option[Long],
skipVolumeData: Option[Boolean]): Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
logger.trace(s"Requested download for annotation: $typ/$id")
for {
Expand Down
11 changes: 11 additions & 0 deletions app/controllers/Application.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.scalableminds.util.accesscontext.DBAccessContext
import com.scalableminds.util.tools.{Fox, FoxImplicits}
import com.scalableminds.webknossos.datastore.rpc.RPC
import com.typesafe.config.ConfigRenderOptions
import io.swagger.annotations.{Api, ApiOperation, ApiResponse, ApiResponses}
import javax.inject.Inject
import models.analytics.{AnalyticsService, FrontendAnalyticsEvent}
import models.user.{MultiUserDAO, User}
Expand All @@ -16,6 +17,7 @@ import utils.{SQLClient, SimpleSQLDAO, StoreModules, WkConf}

import scala.concurrent.ExecutionContext

@Api
class Application @Inject()(multiUserDAO: MultiUserDAO,
analyticsService: AnalyticsService,
releaseInformationDAO: ReleaseInformationDAO,
Expand All @@ -25,6 +27,12 @@ class Application @Inject()(multiUserDAO: MultiUserDAO,
rpc: RPC)(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers)
extends Controller {

@ApiOperation(value = "Information about the version of webKnossos")
@ApiResponses(
Array(
new ApiResponse(code = 200, message = "JSON object containing information about the version of webKnossos"),
new ApiResponse(code = 400, message = "Operation could not be performed. See JSON body for more information.")
))
def buildInfo: Action[AnyContent] = sil.UserAwareAction.async { implicit request =>
for {
schemaVersion <- releaseInformationDAO.getSchemaVersion.futureBox
Expand All @@ -51,6 +59,7 @@ class Application @Inject()(multiUserDAO: MultiUserDAO,
case _ => Fox.successful(None)
}

@ApiOperation(hidden = true, value = "")
def trackAnalyticsEvent(eventType: String): Action[JsObject] = sil.UserAwareAction(validateJson[JsObject]) {
implicit request =>
request.identity.foreach { user =>
Expand All @@ -59,10 +68,12 @@ class Application @Inject()(multiUserDAO: MultiUserDAO,
Ok
}

@ApiOperation(hidden = true, value = "")
def features: Action[AnyContent] = sil.UserAwareAction {
Ok(conf.raw.underlying.getConfig("features").resolve.root.render(ConfigRenderOptions.concise()))
}

@ApiOperation(value = "Health endpoint")
def health: Action[AnyContent] = Action {
Ok
}
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/Controller.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ trait Controller
with I18nSupport
with LazyLogging {

final val badRequestLabel = "Operation could not be performed. See JSON body for more information."

def jsonErrorWrites(errors: JsError)(implicit m: MessagesProvider): JsObject =
Json.obj(
"errors" -> errors.errors.map(error =>
Expand Down

0 comments on commit 1ec60f8

Please sign in to comment.