diff --git a/build.gradle.kts b/build.gradle.kts index 12b6083..2c0d0a7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,6 +92,13 @@ tasks.wrapper { java { toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } +sourceSets { + main { + // bundles the official OMG spec (resources/openapi.json) so the + // /meta/datatypes endpoints can serve its schema definitions + resources.srcDir("resources") + } +} kotlin { jvmToolchain(21) } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt index 482f3c5..6f2eb09 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt @@ -20,6 +20,7 @@ import io.ktor.server.routing.* import kotlinx.serialization.json.Json import io.ktor.server.application.ApplicationStopped import org.openmbee.flexo.sysmlv2.apis.* +import org.openmbee.flexo.sysmlv2.models.Error lateinit var GlobalFlexoConfig: FlexoConfig lateinit var FlexoHttpClient: HttpClient @@ -105,17 +106,26 @@ fun Application.module() { //install(HSTS, ApplicationHstsConfiguration()) // see https://ktor.io/docs/hsts.html install(Resources) install(StatusPages) { + // failures respond with the spec's Error shape so clients can + // parse them uniformly exception { call, cause -> - call.respondText(cause.message ?: "Bad Request", status = HttpStatusCode.BadRequest) + call.respond(HttpStatusCode.BadRequest, + Error(Error.AtType.Error, cause.message ?: "Bad Request")) } exception { call, cause -> - call.respondText(cause.message ?: "Bad Request", status = HttpStatusCode.BadRequest) + call.respond(HttpStatusCode.BadRequest, + Error(Error.AtType.Error, cause.message ?: "Bad Request")) } exception { call, cause -> - call.respondText(cause.message ?: "Not Implemented", status = HttpStatusCode.NotImplemented) + call.respond(HttpStatusCode.NotImplemented, + Error(Error.AtType.Error, cause.message ?: "Not Implemented")) } exception { call, cause -> - call.respondText(cause.message ?: "Internal Server Error", status = HttpStatusCode.InternalServerError) + // do not leak internal details (exception messages may contain + // IRIs, paths, or upstream internals) + call.application.log.error("unhandled exception", cause) + call.respond(HttpStatusCode.InternalServerError, + Error(Error.AtType.Error, "Internal Server Error")) } } routing { diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Http.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Http.kt index c45e08b..596ef9e 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Http.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Http.kt @@ -1,6 +1,8 @@ package org.openmbee.flexo.sysmlv2 import io.ktor.http.* +import io.ktor.server.plugins.* +import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -8,3 +10,30 @@ import io.ktor.server.routing.* suspend fun RoutingContext.notImplemented() { call.respondText("Not implemented", status= HttpStatusCode.NotImplemented) } + +/** + * Respond with a cursor page of [items]: ordered by id, resuming after + * page[after], bounded by page[size], with a Link rel="next" header when + * more items remain. With neither param set, responds with the unpaged + * historical list untouched. + */ +suspend inline fun RoutingContext.respondPage( + items: List, + pageSize: Int?, + pageAfter: String?, + crossinline idOf: (T) -> String +) { + if (pageSize == null && pageAfter == null) { + return call.respond(items) + } + if (pageSize != null && pageSize < 1) { + throw BadRequestException("page[size] must be a positive integer") + } + val remaining = items.sortedBy(idOf).filter { pageAfter == null || idOf(it) > pageAfter } + val page = if (pageSize == null) remaining else remaining.take(pageSize) + if (pageSize != null && remaining.size > pageSize) { + val nextUrl = "${call.request.path()}?page%5Bsize%5D=$pageSize&page%5Bafter%5D=${idOf(page.last())}" + call.response.headers.append(HttpHeaders.Link, "<$nextUrl>; rel=\"next\"") + } + call.respond(page) +} diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Paths.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Paths.kt index 92ed710..0d316c5 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Paths.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Paths.kt @@ -35,7 +35,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/branches") class getBranchesByProject(val projectId: kotlin.String, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/branches") class getBranchesByProject(val projectId: kotlin.String, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get branch by project and ID @@ -71,7 +71,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/commits/{commitId}/changes") class getChangesByProjectCommit(val projectId: kotlin.String, val commitId: kotlin.String, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/commits/{commitId}/changes") class getChangesByProjectCommit(val projectId: kotlin.String, val commitId: kotlin.String, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get commit by project and ID @@ -89,7 +89,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/commits") class getCommitsByProject(val projectId: kotlin.String, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/commits") class getCommitsByProject(val projectId: kotlin.String, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Create commit by project @@ -110,7 +110,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/commits/{compareCommitId}/diff") class diff(val projectId: kotlin.String, val baseCommitId: kotlin.String, val compareCommitId: kotlin.String, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/commits/{compareCommitId}/diff") class diff(val projectId: kotlin.String, val baseCommitId: kotlin.String, val compareCommitId: kotlin.String, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Merge source commit(s) into a target branch @@ -124,7 +124,7 @@ object Paths { * @param pageSize Page size (optional) * @param `data` (optional) */ - @Serializable @Resource("/projects/{projectId}/branches/{targetBranchId}/merge") class merge(val projectId: kotlin.String, val sourceCommitId: kotlin.collections.List, val targetBranchId: kotlin.String, val description: kotlin.String? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null, val `data`: kotlin.collections.List? = null) + @Serializable @Resource("/projects/{projectId}/branches/{targetBranchId}/merge") class merge(val projectId: kotlin.String, val sourceCommitId: kotlin.collections.List, val targetBranchId: kotlin.String, val description: kotlin.String? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null, val `data`: kotlin.collections.List? = null) /** * Get element by project, commit and ID @@ -146,7 +146,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/commits/{commitId}/elements") class getElementsByProjectCommit(val projectId: kotlin.String, val commitId: kotlin.String, val excludeUsed: kotlin.Boolean? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/commits/{commitId}/elements") class getElementsByProjectCommit(val projectId: kotlin.String, val commitId: kotlin.String, val excludeUsed: kotlin.Boolean? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get ProjectUsage that originates the provided element @@ -167,7 +167,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/commits/{commitId}/roots") class getRootsByProjectCommit(val projectId: kotlin.String, val commitId: kotlin.String, val excludeUsed: kotlin.Boolean? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/commits/{commitId}/roots") class getRootsByProjectCommit(val projectId: kotlin.String, val commitId: kotlin.String, val excludeUsed: kotlin.Boolean? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get datatype by ID @@ -183,7 +183,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/meta/datatypes") class getDatatypes(val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/meta/datatypes") class getDatatypes(@SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Delete project by ID @@ -206,7 +206,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects") class getProjects(val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects") class getProjects(@SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Create project @@ -239,7 +239,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/queries") class getQueriesByProject(val projectId: kotlin.String, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/queries") class getQueriesByProject(val projectId: kotlin.String, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get query by project and ID @@ -259,7 +259,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/query-results") class getQueryResultsByProjectIdQuery(val projectId: kotlin.String, val queryRequest: QueryRequest, val commitId: kotlin.String? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/query-results") class getQueryResultsByProjectIdQuery(val projectId: kotlin.String, val queryRequest: QueryRequest, val commitId: kotlin.String? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get query results by project and query @@ -271,7 +271,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/queries/{queryId}/results") class getQueryResultsByProjectIdQueryId(val projectId: kotlin.String, val queryId: kotlin.String, val commitId: kotlin.String? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/queries/{queryId}/results") class getQueryResultsByProjectIdQueryId(val projectId: kotlin.String, val queryId: kotlin.String, val commitId: kotlin.String? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Get query results by project and query definition via POST @@ -283,7 +283,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/query-results") class getQueryResultsByProjectIdQueryPost(val projectId: kotlin.String, val queryRequest: QueryRequest, val commitId: kotlin.String? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/query-results") class getQueryResultsByProjectIdQueryPost(val projectId: kotlin.String, val queryRequest: QueryRequest, val commitId: kotlin.String? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Create query by project @@ -314,7 +314,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/commits/{commitId}/elements/{relatedElementId}/relationships") class getRelationshipsByProjectCommitRelatedElement(val projectId: kotlin.String, val commitId: kotlin.String, val relatedElementId: kotlin.String, val direction: kotlin.String? = null, val excludeUsed: kotlin.Boolean? = null, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/commits/{commitId}/elements/{relatedElementId}/relationships") class getRelationshipsByProjectCommitRelatedElement(val projectId: kotlin.String, val commitId: kotlin.String, val relatedElementId: kotlin.String, val direction: kotlin.String? = null, val excludeUsed: kotlin.Boolean? = null, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Delete tag by project and ID @@ -340,7 +340,7 @@ object Paths { * @param pageBefore Page before (optional) * @param pageSize Page size (optional) */ - @Serializable @Resource("/projects/{projectId}/tags") class getTagsByProject(val projectId: kotlin.String, val pageAfter: kotlin.String? = null, val pageBefore: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Serializable @Resource("/projects/{projectId}/tags") class getTagsByProject(val projectId: kotlin.String, @SerialName("page[after]") val pageAfter: kotlin.String? = null, @SerialName("page[before]") val pageBefore: kotlin.String? = null, @SerialName("page[size]") val pageSize: kotlin.Int? = null) /** * Create tag by project diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt index 9e49148..6957fba 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt @@ -89,13 +89,14 @@ fun Route.BranchApi() { return@get forward(flexoResponse) } // parse the response model, convert it to JSON, and reply to client - call.respond(flexoResponse.parseModel { + val branches = flexoResponse.parseModel { model.listSubjectsWithProperty(RDF.type, MMS.Branch).filterDrop { (it.hasProperty(SYSMLV2.DELETED) || it.uri.uriSuffix == "master") }.mapWith { branchFromResponse(it.outgoing(), path.projectId, it.uri.uriSuffix) }.toList().filterNotNull() - }) + } + respondPage(branches, path.pageSize, path.pageAfter) { it.atId } } get { path -> diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt index cec5999..39af837 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt @@ -29,6 +29,8 @@ import io.ktor.http.* import org.openmbee.flexo.sysmlv2.* import org.openmbee.flexo.sysmlv2.models.Commit import org.openmbee.flexo.sysmlv2.models.CommitRequest +import org.openmbee.flexo.sysmlv2.models.DataIdentity +import org.openmbee.flexo.sysmlv2.models.DataVersion import org.openmbee.flexo.sysmlv2.infrastructure.generateId import org.openmbee.flexo.sysmlv2.infrastructure.requireValidId @@ -42,6 +44,21 @@ fun FlexoModelHandler.commitFromModel( properties: Map?>, projectId: String ): Commit { + // expose previousCommit only when the parent is itself a user-visible + // commit: flexo auto-creates a root commit (its own mms:parent is + // rdf:nil) that this API hides from the commit list, so linking to it + // would produce a dangling reference. When the model lacks the parent's + // triples (e.g. a freshly created commit response), the linkage is + // unknowable here and stays null — clients can re-GET the commit. + val previousCommit = properties[MMS.parent]?.resource() + ?.takeIf { it != MMS.nil } + ?.let { model.getResource(it.uri) } + ?.takeIf { parent -> + parent.hasProperty(MMS.parent) && + parent.getProperty(MMS.parent).`object` != MMS.nil + } + ?.let { Identified(atId = it.uri.uriSuffix) } + // generate commit object return Commit( atId = commitIri.uriSuffix, @@ -51,10 +68,7 @@ fun FlexoModelHandler.commitFromModel( // dct:description for forward/backward compatibility. description = properties[MMS.message]?.literal()?: properties[DCTerms.description]?.literal()?: "", owningProject = Identified(atId = projectId), - //previousCommit = properties[MMS.parent]?.map { - // Identified(atId = UUID.fromString(it.asResource().uri.uriSuffix)) - //}?: emptyList() - previousCommit = null + previousCommit = previousCommit ) } @@ -74,21 +88,105 @@ fun JsonPrimitive.toRdfLiteralNode(): Node { return NodeFactory.createLiteralDT(content, datatype) } +// index every element in the model by IRI as its API JSON shape +fun FlexoModelHandler.elementsByIri(): Map = + model.listSubjects().toList() + .filter { !it.isAnon && it.uri.startsWith(SYSMLV2.ELEMENT) } + .associate { it.uri to extractModelElementToJson(it.uri) } + +// change ids must be stable across requests: derive them from the commit +// and element ids +fun changeId(commitId: String, elementId: String): String = + java.util.UUID.nameUUIDFromBytes("$commitId:$elementId".toByteArray()).toString() + +/** + * Compute a commit's changes by diffing its model graph against its + * parent's (layer1 stores the raw SPARQL patch, which cannot be mapped + * back to per-element DataVersions). + * + * Returns (failureToForward, changes): a non-null failure must be + * forwarded to the client; null changes means the commit does not exist. + */ +suspend fun RoutingContext.computeCommitChanges( + projectId: String, commitId: String +): Pair?> { + // resolve the commit and its parent from the commit collection + val commitsResponse = flexoRequestGet { + orgPath("/repos/$projectId/commits") + } + if (commitsResponse.isFailure()) return Pair(commitsResponse, null) + var found = false + var parentId: String? = null + commitsResponse.parseModel { + model.listSubjectsWithProperty(RDF.type, MMS.Commit).toList() + .firstOrNull { it.uri.uriSuffix == commitId } + ?.let { commit -> + found = true + parentId = commit.outgoing()[MMS.parent]?.resource() + ?.takeIf { it != MMS.nil }?.uri?.uriSuffix + } + } + if (!found) return Pair(null, null) + + val after = flexoRequestGet { + orgPath("/repos/$projectId/locks/Commit.$commitId/graph") + } + if (after.isFailure()) return Pair(after, null) + val afterElements = after.parseModel { elementsByIri() } + + val beforeElements = parentId?.let { pid -> + val before = flexoRequestGet { + orgPath("/repos/$projectId/locks/Commit.$pid/graph") + } + // a parent graph that cannot be materialized (e.g. the empty root + // commit) diffs against an empty baseline + if (before.isFailure()) emptyMap() else before.parseModel { elementsByIri() } + } ?: emptyMap() + + val changes = (afterElements.keys + beforeElements.keys).sorted().mapNotNull { iri -> + val payload = afterElements[iri] + // unchanged elements are not part of the commit's changes + if (payload == beforeElements[iri]) return@mapNotNull null + val elementId = iri.substringAfterLast(':') + DataVersion( + atId = changeId(commitId, elementId), + atType = DataVersion.AtType.DataVersion, + identity = DataIdentity(elementId, DataIdentity.AtType.DataIdentity), + payload = payload + ) + } + return Pair(null, changes) +} + fun Route.CommitApi() { - get { - throw NotImplementedError() + get { params -> + requireValidId(params.projectId, "projectId") + requireValidId(params.commitId, "commitId") + requireValidId(params.changeId, "changeId") + val (failure, changes) = computeCommitChanges(params.projectId, params.commitId) + failure?.let { return@get forward(it) } + val change = changes?.firstOrNull { it.atId == params.changeId } + ?: return@get call.respond(HttpStatusCode.NotFound) + call.respond(change) } - get { - throw NotImplementedError() + get { params -> + requireValidId(params.projectId, "projectId") + requireValidId(params.commitId, "commitId") + val (failure, changes) = computeCommitChanges(params.projectId, params.commitId) + failure?.let { return@get forward(it) } + changes ?: return@get call.respond(HttpStatusCode.NotFound) + respondPage(changes, params.pageSize, params.pageAfter) { it.atId } } get { getCommit -> requireValidId(getCommit.projectId, "projectId") requireValidId(getCommit.commitId, "commitId") - // submit GET request to retrieve project metadata + // fetch the whole commit collection: layer1's single-commit response + // does not include the parent commit's triples, which are needed to + // resolve previousCommit (and to hide flexo's auto-created root) val flexoResponse = flexoRequestGet { - orgPath("/repos/${getCommit.projectId}/commits/${getCommit.commitId}") + orgPath("/repos/${getCommit.projectId}/commits") } // forward failures to client @@ -97,9 +195,12 @@ fun Route.CommitApi() { } // parse the response model, convert it to JSON, and reply to client val commit = flexoResponse.parseModel { - model.listSubjectsWithProperty(RDF.type, MMS.Commit).mapWith { - commitFromModel(it.uri, it.outgoing(), getCommit.projectId) - }.toList().firstOrNull() + model.listSubjectsWithProperty(RDF.type, MMS.Commit) + .toList() + .firstOrNull { it.uri.uriSuffix == getCommit.commitId } + // the hidden root commit is not addressable through this API + ?.takeIf { it.outgoing()[MMS.parent]?.resource() != MMS.nil } + ?.let { commitFromModel(it.uri, it.outgoing(), getCommit.projectId) } } ?: return@get call.respond(HttpStatusCode.NotFound) call.respond(commit) } @@ -126,7 +227,7 @@ fun Route.CommitApi() { } } commits.sortByDescending {it.created} - call.respond(commits) + respondPage(commits, getCommits.pageSize, getCommits.pageAfter) { it.atId } } post("/projects/{projectId}/commits") { commit -> diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt index 70373cd..36349be 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt @@ -13,6 +13,8 @@ package org.openmbee.flexo.sysmlv2.apis import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.plugins.* +import io.ktor.server.request.* import io.ktor.server.resources.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -37,25 +39,24 @@ fun modelElementConstructQuery(elementTarget: String="?__element"): String { """.trimIndent() } -fun listElementsConstructQuery(limit: Int?=null, offset: Int?=null): String { - if (limit == null && offset == null) return """ - construct { - ?s ?p ?o . - } where { - ?s ?p ?o . - } - """.trimIndent() +/** + * Cursor-paged element page: subjects are ordered by IRI (the element URN + * embeds the id, so this is id order), optionally resuming after a cursor. + */ +fun pagedElementsConstructQuery(afterElementIri: String?, limit: Int): String { + val afterFilter = afterElementIri?.let { """filter(str(?e) > "${escapeRdfDoubleQuotedLiteralContents(it)}")""" } ?: "" return """ - prefix sysml: construct { ?e ?element_p ?element_o . } where { { - select ?e - where { - ?e sysml:elementId ?id . - } order by ?id limit $limit offset $offset + select distinct ?e + where { + ?e ?any_p ?any_o . + filter(isIRI(?e) && strstarts(str(?e), "${SYSMLV2.ELEMENT}")) + $afterFilter + } order by ?e limit $limit } ?e ?element_p ?element_o . } @@ -207,25 +208,66 @@ fun Route.ElementApi() { get { getElements -> requireValidId(getElements.projectId, "projectId") requireValidId(getElements.commitId, "commitId") - // submit POST request to query model - val flexoResponse = flexoRequestGet { - orgPath("/repos/${getElements.projectId}/locks/Commit.${getElements.commitId}/graph") + val pageSize = getElements.pageSize + getElements.pageAfter?.let { requireValidId(it, "page[after]") } + + // unpaged: dump the whole model graph (historical behavior) + if (pageSize == null) { + val flexoResponse = flexoRequestGet { + orgPath("/repos/${getElements.projectId}/locks/Commit.${getElements.commitId}/graph") + } + + // forward failures to client + if(flexoResponse.isFailure()) { + return@get forward(flexoResponse) + } + // parse the response model, extract the elements to JSON, and reply to client + val result = buildJsonArray { + flexoResponse.parseModel { + for(subject in model.listSubjects()) { + if (subject.isAnon) continue + add(extractModelElementToJson(subject.uri)) + } + } + } + return@get call.respond(result) + } + + if (pageSize < 1) { + throw BadRequestException("page[size] must be a positive integer") + } + + // paged: cursor over element IRIs at the SPARQL level, fetching one + // extra element to detect whether a next page exists + val afterIri = getElements.pageAfter?.let { SYSMLV2.element(it).uri } + val flexoResponse = flexoRequestPost { + orgPath("/repos/${getElements.projectId}/locks/Commit.${getElements.commitId}/query") + sparqlQuery { + pagedElementsConstructQuery(afterIri, pageSize + 1) + } } // forward failures to client if(flexoResponse.isFailure()) { return@get forward(flexoResponse) } - // parse the response model, extract the elements to JSON, and reply to client - val result = buildJsonArray { - flexoResponse.parseModel { - for(subject in model.listSubjects()) { - if (subject.isAnon) continue - add(extractModelElementToJson(subject.uri)) - } + + val (page, hasNext) = flexoResponse.parseModel { + val ordered = model.listSubjects().toList() + .filter { !it.isAnon } + .map { it.uri } + .sorted() + val page = ordered.take(pageSize).map { iri -> + iri.urnSuffix to extractModelElementToJson(iri) } + page to (ordered.size > pageSize) } - call.respond(result) + + if (hasNext) { + val nextUrl = "${call.request.path()}?page%5Bsize%5D=$pageSize&page%5Bafter%5D=${page.last().first}" + call.response.headers.append(HttpHeaders.Link, "<$nextUrl>; rel=\"next\"") + } + call.respond(buildJsonArray { page.forEach { add(it.second) } }) } //TODO this is wrong get { @@ -299,15 +341,14 @@ fun Route.ElementApi() { } // parse the response model, extract the elements to JSON, and reply to client - val result = buildJsonArray { - flexoResponse.parseModel { - for(subject in model.listSubjects()) { - if (subject.isAnon) continue - add(extractModelElementToJson(subject.uri)) - } - } + val roots = flexoResponse.parseModel { + model.listSubjects().toList() + .filter { !it.isAnon } + .map { extractModelElementToJson(it.uri) } + } + respondPage(roots, it.pageSize, it.pageAfter) { root -> + root["@id"]!!.jsonPrimitive.content } - call.respond(result) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/MetaApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/MetaApi.kt index 3eb949c..53a90ed 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/MetaApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/MetaApi.kt @@ -13,47 +13,41 @@ package org.openmbee.flexo.sysmlv2.apis import io.ktor.http.* import io.ktor.server.application.* -import io.ktor.server.auth.* -import io.ktor.server.response.* -import org.openmbee.flexo.sysmlv2.Paths -import io.ktor.server.resources.options import io.ktor.server.resources.get -import io.ktor.server.resources.post -import io.ktor.server.resources.put -import io.ktor.server.resources.delete -import io.ktor.server.resources.head -import io.ktor.server.resources.patch +import io.ktor.server.response.* import io.ktor.server.routing.* +import kotlinx.serialization.json.* +import org.openmbee.flexo.sysmlv2.Paths -fun Route.MetaApi() { +private const val JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema" + +// the API's datatype schemas, served from the bundled official OMG spec +// (resources/openapi.json); parsed once on first use +private val datatypeSchemas: Map by lazy { + val stream = object {}.javaClass.classLoader.getResourceAsStream("openapi.json") + ?: return@lazy emptyMap() + val doc = stream.reader().use { Json.parseToJsonElement(it.readText()) }.jsonObject + doc["components"]?.jsonObject?.get("schemas")?.jsonObject + ?.mapValues { (_, schema) -> schema.jsonObject } + ?: emptyMap() +} - get { - val exampleContentString = """{ - "${'$'}schema" : "${'$'}schema", - "${'$'}defs" : { - "key" : "" - }, - "additionalProperties" : true, - "title" : "title", - "type" : "type", - "properties" : { - "key" : "" - }, - "required" : [ "required", "required" ], - "${'$'}id" : "https://openapi-generator.tech" - }""" +fun Route.MetaApi() { - call.respondText(exampleContentString, ContentType.Application.Json) + get { params -> + val schema = datatypeSchemas[params.datatypeId.toString()] + ?: return@get call.respond(HttpStatusCode.NotFound) + call.respond(buildJsonObject { + put("\$schema", JSON_SCHEMA_DIALECT) + schema.forEach { (key, value) -> put(key, value) } + }) } get { - val exampleContentString = """{ - "${'$'}schema" : "${'$'}schema", - "${'$'}defs" : { - "key" : "" - } - }""" - call.respondText(exampleContentString, ContentType.Application.Json) + call.respond(buildJsonObject { + put("\$schema", JSON_SCHEMA_DIALECT) + put("\$defs", JsonObject(datatypeSchemas)) + }) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt index 88305c1..a213ee1 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt @@ -140,7 +140,7 @@ fun Route.ProjectApi() { } // get all projects - get { + get { params -> val flexoResponse = flexoRequestGet { orgPath("/repos") } @@ -148,7 +148,7 @@ fun Route.ProjectApi() { return@get forward(flexoResponse) } // parse the response model, convert it to JSON, and reply to client - call.respond(flexoResponse.parseModel { + val projects = flexoResponse.parseModel { // find all repos and transform each one into a project by its outgoing triples // filter out deleted = true model.listSubjectsWithProperty(RDF.type, MMS.Repo).filterDrop { @@ -156,7 +156,8 @@ fun Route.ProjectApi() { }.mapWith { projectFromResponse(it.outgoing()) }.toList().filterNotNull() - }) + } + respondPage(projects, params.pageSize, params.pageAfter) { it.atId } } // create new project via POST diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt index 36cbb2a..8cb6c6d 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt @@ -190,6 +190,12 @@ suspend fun RoutingContext.runQuery( } } +// apply a query's select projection: keep only the requested properties +fun JsonObject.projectTo(select: List?): JsonObject { + if (select.isNullOrEmpty()) return this + return JsonObject(filterKeys { it in select }) +} + suspend fun RoutingContext.getQuery(projectId: String, queryId: String): Pair { val uri = SYSMLV2.query(queryId).uri val flexoResponse = flexoRequestPost { @@ -263,11 +269,12 @@ fun Route.QueryApi() { return@get forward(flexoResponse) } // parse the response model, convert it to JSON, and reply to client - call.respond(flexoResponse.parseModel { + val queries = flexoResponse.parseModel { model.listSubjects().mapWith { it2 -> queryFromResponse(it2.outgoing()) }.toList() - }) + } + respondPage(queries, it.pageSize, it.pageAfter) { query -> query.atId } } get { @@ -311,7 +318,7 @@ fun Route.QueryApi() { flexoResponse.parseModel { for(subject in model.listSubjects()) { if (subject.isAnon) continue - add(extractModelElementToJson(subject.uri)) + add(extractModelElementToJson(subject.uri).projectTo(query.select)) } } }) @@ -332,7 +339,7 @@ fun Route.QueryApi() { flexoResponse.parseModel { for(subject in model.listSubjects()) { if (subject.isAnon) continue - add(extractModelElementToJson(subject.uri)) + add(extractModelElementToJson(subject.uri).projectTo(it.select)) } } }) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt index dfa6072..2b661f7 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt @@ -110,7 +110,7 @@ fun Route.TagApi() { return@get forward(flexoResponse) } // parse the response model, convert it to JSON, and reply to client - call.respond(flexoResponse.parseModel { + val tags = flexoResponse.parseModel { // find all locks and transform each one into a tag by its outgoing triples model.listSubjectsWithProperty(RDF.type, MMS.Lock).filterDrop { // ignore flexo created locks and deleted @@ -118,7 +118,8 @@ fun Route.TagApi() { }.mapWith { tagFromResponse(it.outgoing(), path.projectId, it.uri.uriSuffix) }.toList().filterNotNull() - }) + } + respondPage(tags, path.pageSize, path.pageAfter) { it.atId } } post("/projects/{projectId}/tags") { request -> diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/DataVersion.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/DataVersion.kt index eeaecb5..b984a4d 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/DataVersion.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/DataVersion.kt @@ -31,7 +31,8 @@ data class DataVersion( @SerialName("@type") val atType: DataVersion.AtType, val identity: DataIdentity, - val payload: JsonObject + // null payload denotes a deleted element + val payload: JsonObject? ) { /** diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/ChangesTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ChangesTest.kt new file mode 100644 index 0000000..b219bcf --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ChangesTest.kt @@ -0,0 +1,99 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * GET .../commits/{id}/changes reports the elements a commit created, + * modified, or deleted (previously a 501 stub). + */ +class ChangesTest : ProjectAny() { + val keepId = "aaaaaaa1-0000-4000-8000-000000000001" + val editId = "aaaaaaa1-0000-4000-8000-000000000002" + val dropId = "aaaaaaa1-0000-4000-8000-000000000003" + + val firstChange = """ + { + "change": [ + {"@type": "DataVersion", "identity": {"@id": "$keepId"}, "payload": {"@type": "PartDefinition", "name": "keep"}}, + {"@type": "DataVersion", "identity": {"@id": "$editId"}, "payload": {"@type": "PartDefinition", "name": "before"}}, + {"@type": "DataVersion", "identity": {"@id": "$dropId"}, "payload": {"@type": "PartDefinition", "name": "drop"}} + ] + } + """.trimIndent() + + val secondChange = """ + { + "change": [ + {"@type": "DataVersion", "identity": {"@id": "$editId"}, "payload": {"@type": "PartDefinition", "name": "after"}}, + {"@type": "DataVersion", "identity": {"@id": "$dropId"}, "payload": null} + ] + } + """.trimIndent() + + init { + "the first commit reports every created element" { + testApplication { + val commitId = commitChanges(demoProjectId, firstChange).atId() + + val response = getChanges(demoProjectId, commitId) + response shouldHaveStatus HttpStatusCode.OK + val changes = Json.parseToJsonElement(response.bodyAsText()).jsonArray + val identities = changes.map { it.jsonObject.nestedAtId("identity") } + identities shouldContainExactlyInAnyOrder listOf(keepId, editId, dropId) + changes.forEach { + it.jsonObject["@type"]!!.jsonPrimitive.content shouldBe "DataVersion" + } + } + } + + "a later commit reports modification and deletion, not untouched elements" { + testApplication { + commitChanges(demoProjectId, firstChange).atId() + val second = commitChanges(demoProjectId, secondChange).atId() + + val changes = Json.parseToJsonElement( + getChanges(demoProjectId, second).bodyAsText()).jsonArray + val byIdentity = changes.associate { + it.jsonObject.nestedAtId("identity") to it.jsonObject["payload"]!! + } + byIdentity.keys shouldContainExactlyInAnyOrder setOf(editId, dropId) + // modified element carries its new payload + byIdentity[editId]!!.jsonObject["name"]!!.jsonPrimitive.content shouldBe "after" + // deleted element carries a null payload + byIdentity[dropId] shouldBe JsonNull + } + } + + "a single change is addressable by its id" { + testApplication { + val commitId = commitChanges(demoProjectId, firstChange).atId() + + val changes = Json.parseToJsonElement( + getChanges(demoProjectId, commitId).bodyAsText()).jsonArray + val changeId = changes.first().jsonObject["@id"]!!.jsonPrimitive.content + + val response = httpGet( + "/projects/$demoProjectId/commits/$commitId/changes/$changeId") + response shouldHaveStatus HttpStatusCode.OK + response.bodyAsJsonObject()["@id"]!!.jsonPrimitive.content shouldBe changeId + } + } + + "changes for a nonexistent commit is a 404" { + testApplication { + getChanges(demoProjectId, "00000000-dead-4000-8000-000000000000") + .shouldHaveStatus(HttpStatusCode.NotFound) + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/CollectionPaginationTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/CollectionPaginationTest.kt new file mode 100644 index 0000000..41460a2 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/CollectionPaginationTest.kt @@ -0,0 +1,75 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Cursor pagination on the collection list endpoints (branches here as + * the representative; all go through the same respondPage helper). + */ +class CollectionPaginationTest : ProjectAny() { + val elementChange = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "99999999-9999-4999-8999-999999999999"}, + "payload": {"@type": "PartDefinition", "name": "anything"} + } + ] + } + """.trimIndent() + + init { + "branch list pages by id with a next link" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val defaultBranchId = getProject(demoProjectId).bodyAsJsonObject() + .nestedAtId("defaultBranch") + // the list contains the default branch plus the three created here + val created = ((1..3).map { n -> + createBranch(demoProjectId, commitId, "branch-$n").atId() + } + defaultBranchId).sorted() + + val first = httpGet("/projects/$demoProjectId/branches?page%5Bsize%5D=2") + first shouldHaveStatus HttpStatusCode.OK + val firstIds = Json.parseToJsonElement(first.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + firstIds.size shouldBe 2 + first.headers[HttpHeaders.Link].shouldNotBeNull() + + val second = httpGet( + "/projects/$demoProjectId/branches?page%5Bsize%5D=2&page%5Bafter%5D=${firstIds.last()}") + val secondIds = Json.parseToJsonElement(second.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + secondIds.size shouldBe 2 + second.headers[HttpHeaders.Link].shouldBeNull() + + (firstIds + secondIds) shouldContainExactly created + } + } + + "unpaged branch list is unchanged" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + createBranch(demoProjectId, commitId, "solo").atId() + + val response = getBranches(demoProjectId) + response shouldHaveStatus HttpStatusCode.OK + // default branch + the one created here + Json.parseToJsonElement(response.bodyAsText()).jsonArray.size shouldBe 2 + response.headers[HttpHeaders.Link].shouldBeNull() + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementPaginationTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementPaginationTest.kt new file mode 100644 index 0000000..6012b93 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementPaginationTest.kt @@ -0,0 +1,96 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Cursor pagination on GET .../elements: page[size] bounds each response, + * page[after] resumes after a cursor, and a Link rel="next" header is + * present exactly when more elements remain. + */ +class ElementPaginationTest : ProjectAny() { + // ids chosen so lexicographic order is the numeric order + val elementIds = (1..5).map { "88888888-8888-4888-8888-00000000000$it" } + + val seedChanges = """ + { + "change": [ + ${elementIds.joinToString(",\n") { + """{"@type": "DataVersion", "identity": {"@id": "$it"}, "payload": {"@type": "PartDefinition", "name": "part-$it"}}""" + }} + ] + } + """.trimIndent() + + init { + "walking pages of 2 visits every element exactly once in order" { + testApplication { + val commitId = commitChanges(demoProjectId, seedChanges).atId() + + val seen = mutableListOf() + var after: String? = null + var pages = 0 + while (true) { + val cursor = after?.let { "&page%5Bafter%5D=$it" } ?: "" + val response = httpGet( + "/projects/$demoProjectId/commits/$commitId/elements?page%5Bsize%5D=2$cursor") + response shouldHaveStatus HttpStatusCode.OK + val ids = Json.parseToJsonElement(response.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + seen.addAll(ids) + pages++ + + val link = response.headers[HttpHeaders.Link] + if (link == null) break + link shouldContain "rel=\"next\"" + after = ids.last() + } + + pages shouldBe 3 + seen shouldContainExactly elementIds + } + } + + "a page larger than the model has no next link" { + testApplication { + val commitId = commitChanges(demoProjectId, seedChanges).atId() + + val response = httpGet( + "/projects/$demoProjectId/commits/$commitId/elements?page%5Bsize%5D=50") + response shouldHaveStatus HttpStatusCode.OK + Json.parseToJsonElement(response.bodyAsText()).jsonArray.size shouldBe 5 + response.headers[HttpHeaders.Link].shouldBeNull() + } + } + + "the first page advertises a next link" { + testApplication { + val commitId = commitChanges(demoProjectId, seedChanges).atId() + + val response = httpGet( + "/projects/$demoProjectId/commits/$commitId/elements?page%5Bsize%5D=2") + response.headers[HttpHeaders.Link].shouldNotBeNull() + } + } + + "page[size] of zero is a 400" { + testApplication { + val commitId = commitChanges(demoProjectId, seedChanges).atId() + + httpGet("/projects/$demoProjectId/commits/$commitId/elements?page%5Bsize%5D=0") + .shouldHaveStatus(HttpStatusCode.BadRequest) + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/ErrorShapeTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ErrorShapeTest.kt new file mode 100644 index 0000000..ec797c7 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ErrorShapeTest.kt @@ -0,0 +1,37 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeBlank +import io.ktor.http.* +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Failures respond with the spec's Error object, not plain text, so + * official clients can parse them. + */ +class ErrorShapeTest : ProjectAny() { + init { + "a 400 carries a parseable Error object" { + testApplication { + val response = httpPost("/projects/$demoProjectId/queries") { + setJsonBody("""{ "@type": "Query" }""") + } + response shouldHaveStatus HttpStatusCode.BadRequest + val error = response.bodyAsJsonObject() + error["@type"]!!.jsonPrimitive.content shouldBe "Error" + error["description"]!!.jsonPrimitive.content.shouldNotBeBlank() + } + } + + "a 501 stub carries a parseable Error object" { + testApplication { + val response = httpGet( + "/projects/$demoProjectId/commits/00000000-dead-4000-8000-000000000000/diff?baseCommitId=00000000-dead-4000-8000-000000000001") + response shouldHaveStatus HttpStatusCode.NotImplemented + response.bodyAsJsonObject()["@type"]!!.jsonPrimitive.content shouldBe "Error" + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/MetaTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/MetaTest.kt new file mode 100644 index 0000000..73eb193 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/MetaTest.kt @@ -0,0 +1,46 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.shouldBe +import io.ktor.http.* +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * /meta/datatypes serves the official spec's schema definitions + * (previously a hardcoded openapi-generator example payload). + */ +class MetaTest : CommonSpec() { + init { + "GET /meta/datatypes lists the spec schemas under \$defs" { + testApplication { + val response = httpGet("/meta/datatypes") + response shouldHaveStatus HttpStatusCode.OK + val body = response.bodyAsJsonObject() + body["\$schema"]!!.jsonPrimitive.content shouldBe + "https://json-schema.org/draft/2020-12/schema" + val defs = body["\$defs"]!!.jsonObject + defs shouldContainKey "Project" + defs shouldContainKey "PartDefinition" + } + } + + "GET /meta/datatypes/{id} returns the named schema" { + testApplication { + val response = httpGet("/meta/datatypes/PartDefinition") + response shouldHaveStatus HttpStatusCode.OK + val body = response.bodyAsJsonObject() + body["title"]!!.jsonPrimitive.content shouldBe "PartDefinition" + } + } + + "GET an unknown datatype is a 404" { + testApplication { + httpGet("/meta/datatypes/NotARealDatatype") + .shouldHaveStatus(HttpStatusCode.NotFound) + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/PreviousCommitTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/PreviousCommitTest.kt new file mode 100644 index 0000000..eafa7cf --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/PreviousCommitTest.kt @@ -0,0 +1,60 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.shouldBe +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Commit lineage: previousCommit links each commit to its parent, except + * that flexo's auto-created (hidden) root commit is never referenced. + */ +class PreviousCommitTest : ProjectAny() { + fun change(elementId: String, name: String) = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$elementId"}, + "payload": {"@type": "PartDefinition", "name": "$name"} + } + ] + } + """.trimIndent() + + init { + "single GET resolves previousCommit; first commit has none" { + testApplication { + val first = commitChanges(demoProjectId, change("55555555-5555-4555-8555-000000000001", "one")).atId() + val second = commitChanges(demoProjectId, change("55555555-5555-4555-8555-000000000002", "two")).atId() + + val firstBody = getCommit(demoProjectId, first).bodyAsJsonObject() + (firstBody["previousCommit"] ?: JsonNull) shouldBe JsonNull + + val secondBody = getCommit(demoProjectId, second).bodyAsJsonObject() + secondBody.nestedAtId("previousCommit") shouldBe first + } + } + + "commit list carries the same lineage" { + testApplication { + val first = commitChanges(demoProjectId, change("55555555-5555-4555-8555-000000000003", "one")).atId() + val second = commitChanges(demoProjectId, change("55555555-5555-4555-8555-000000000004", "two")).atId() + + val response = getCommits(demoProjectId) + response shouldHaveStatus HttpStatusCode.OK + val byId = Json.parseToJsonElement(response.bodyAsText()).jsonArray + .associateBy { it.jsonObject["@id"]!!.jsonPrimitive.content } + + (byId[first]!!.jsonObject["previousCommit"] ?: JsonNull) shouldBe JsonNull + byId[second]!!.nestedAtId("previousCommit") shouldBe first + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt index 58f73a5..ba2fd68 100644 --- a/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt @@ -2,6 +2,7 @@ package org.openmbee.flexo.sysmlv2 import io.kotest.assertions.ktor.client.shouldHaveStatus import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.ApplicationTestBuilder @@ -162,6 +163,44 @@ class QueryResultsTest : ProjectAny() { } } + "POST query-results - select projects the result properties" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val response = httpPost("/projects/$demoProjectId/query-results") { + setJsonBody( + """ + { + "@type": "Query", + "select": ["@id", "name"], + "where": {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha"]} + } + """.trimIndent() + ) + } + response shouldHaveStatus HttpStatusCode.OK + val results = Json.parseToJsonElement(response.bodyAsText()).jsonArray + results.size shouldBe 1 + val element = results[0].jsonObject + element.keys shouldBe setOf("@id", "name") + element["name"]!!.jsonPrimitive.content shouldBe "Alpha" + } + } + + "GET queries/{queryId}/results - applies the saved select projection" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + // createQuery stores select = ["@id"] + val queryId = createQuery(demoProjectId, "name", "Beta").atId() + + val response = httpGet("/projects/$demoProjectId/queries/$queryId/results") + response shouldHaveStatus HttpStatusCode.OK + val results = Json.parseToJsonElement(response.bodyAsText()).jsonArray + results.size shouldBe 1 + results[0].jsonObject.keys shouldBe setOf("@id") + results[0].jsonObject["@id"]!!.jsonPrimitive.content shouldBe betaId + } + } + "POST queries - missing select is a 400, not a server error" { testApplication { val response = httpPost("/projects/$demoProjectId/queries") {