Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/src/main/kotlin/app/accrescent/server/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fun main() {
)
val consoleApi = ConsoleApiImpl(
dataStore = dataStore,
idGenerator = IdGenerator(randomSource),
timestampSource = timestampSource,
adminExternalUserId = config.authz.adminExternalUserId,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ class MemoryDataStore(vertx: Vertx) : DataStore {
* @property connection the connection the transaction is running on.
*/
private class MemoryTransaction(private val connection: SqlConnection) : DataStore.Transaction() {
override suspend fun countAppDraftsInOrg(
organizationId: String,
): DataStoreResult<NonNegativeLong> = either {
connection
.executeSingleQuery(
"SELECT COUNT(*) FROM app_drafts WHERE organization_id = ?",
Tuple.of(organizationId),
)
.requireNonNegativeLong(0)
}

override suspend fun createAppDraft(
appDraftId: String,
organizationId: String,
createTime: OffsetDateTime,
): DataStoreResult<Unit> = either {
connection.executeSingleUpdate(
"INSERT INTO app_drafts (id, organization_id, create_time) VALUES (?, ?, ?)",
Tuple.of(appDraftId, organizationId, createTime),
)
}

override suspend fun createOrgWithOwner(
organizationId: String,
userId: String,
Expand Down Expand Up @@ -163,15 +185,22 @@ private class MemoryTransaction(private val connection: SqlConnection) : DataSto
organizationId: String,
): DataStoreResult<Option<OrgApiView>> = either {
connection.executeOptionalQuery(
"SELECT id FROM organizations WHERE id = ?",
"""
SELECT
id,
(SELECT COUNT(*) FROM app_drafts WHERE organization_id = organizations.id)
FROM organizations
WHERE id = ?
""".trimIndent(),
Tuple.of(organizationId),
)
.map {
// Apps and app drafts aren't stored yet, so organizations never have any
OrgApiView(
id = it.requireValue<String>(0),
// Apps aren't stored yet, so organizations never have any published
publishedAppCount = NonNegativeLong.MIN_VALUE,
activeAppDraftCount = NonNegativeLong.MIN_VALUE,
// App drafts can't be reviewed yet, so all of them are active
activeAppDraftCount = it.requireNonNegativeLong(1),
)
}
}
Expand Down Expand Up @@ -215,6 +244,7 @@ private class MemoryTransaction(private val connection: SqlConnection) : DataSto
request: HasPermissionRequest,
): DataStoreResult<Boolean> = either {
val sql = when (request) {
is HasPermissionRequest.CreateAppDraft,
is HasPermissionRequest.ViewOrganization ->
"SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND owner_user_id = ?)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,28 @@ class PostgresqlDataStore(vertx: Vertx, config: DataStoreConfig.Postgresql) : Da
private class PostgresqlTransaction(
private val connection: SqlConnection,
) : DataStore.Transaction() {
override suspend fun countAppDraftsInOrg(
organizationId: String,
): DataStoreResult<NonNegativeLong> = either {
connection
.executeSingleQuery(
"SELECT COUNT(*) FROM app_drafts WHERE organization_id = ?",
Tuple.of(organizationId),
)
.requireNonNegativeLong(0)
}

override suspend fun createAppDraft(
appDraftId: String,
organizationId: String,
createTime: OffsetDateTime,
): DataStoreResult<Unit> = either {
connection.executeSingleUpdate(
"INSERT INTO app_drafts (id, organization_id, create_time) VALUES (?, ?, ?)",
Tuple.of(appDraftId, organizationId, createTime),
)
}

override suspend fun createOrgWithOwner(
organizationId: String,
userId: String,
Expand Down Expand Up @@ -209,15 +231,22 @@ private class PostgresqlTransaction(
organizationId: String,
): DataStoreResult<Option<OrgApiView>> = either {
connection.executeOptionalQuery(
"SELECT id FROM organizations WHERE id = ?",
"""
SELECT
id,
(SELECT COUNT(*) FROM app_drafts WHERE organization_id = organizations.id)
FROM organizations
WHERE id = ?
""".trimIndent(),
Tuple.of(organizationId),
)
.map {
// Apps and app drafts aren't stored yet, so organizations never have any
OrgApiView(
id = it.requireValue<String>(0),
// Apps aren't stored yet, so organizations never have any published
publishedAppCount = NonNegativeLong.MIN_VALUE,
activeAppDraftCount = NonNegativeLong.MIN_VALUE,
// App drafts can't be reviewed yet, so all of them are active
activeAppDraftCount = it.requireNonNegativeLong(1),
)
}
}
Expand Down Expand Up @@ -261,6 +290,7 @@ private class PostgresqlTransaction(
request: HasPermissionRequest,
): DataStoreResult<Boolean> = either {
val sql = when (request) {
is HasPermissionRequest.CreateAppDraft,
is HasPermissionRequest.ViewOrganization ->
"SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND owner_user_id = ?)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package app.accrescent.server.adapters.driven.datastore2.vertx

import app.accrescent.server.core.Error
import app.accrescent.server.core.NonNegativeLong
import app.accrescent.server.core.causedBy
import app.accrescent.server.core.downcast
import app.accrescent.server.core.toEitherBind
Expand All @@ -31,6 +32,24 @@ inline fun <reified T : Any> Row.requireValue(columnIndex: Int): T {
}
}

/**
* Gets a required row value of SQL type `bigint` as a [NonNegativeLong].
*
* Raises [DataStoreError.IllegalState] if the fetched value is `null`, not of type [Long], or
* negative.
*
* @return the value fetched from the database.
*/
context(_: Raise<DataStoreError>)
fun Row.requireNonNegativeLong(columnIndex: Int): NonNegativeLong {
val value = requireValue<Long>(columnIndex)

return NonNegativeLong.new(value).toEitherBind {
DataStoreError.IllegalState()
.causedBy(ColumnValueOutOfRange(columnIndex, value, NonNegativeLong::class))
}
}

/**
* Gets an optional row value.
*
Expand All @@ -51,6 +70,19 @@ inline fun <reified T : Any> Row.getSafeValue(columnIndex: Int): Option<T> {
}
}

/**
* A row value was out of the range of the type it was converted to.
*
* @param columnIndex the index of the column the value was fetched from.
* @param value the value fetched from the database.
* @param target the type the value was converted to.
*/
class ColumnValueOutOfRange(columnIndex: Int, value: Long, target: KClass<*>) : Error() {
override val message =
"expected column $columnIndex to be in the range of type ${target.qualifiedName}, but " +
"it was $value"
}

/**
* A row value was of a different type than expected.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,33 @@

package app.accrescent.server.adapters.driving.api.console.grpc

import app.accrescent.server.domain.ports.driving.api.console2.ConsoleApi
import arrow.core.getOrElse
import build.buf.gen.accrescent.console.v1.AppDraftServiceGrpcKt
import build.buf.gen.accrescent.console.v1.CreateAppDraftRequest
import kotlin.coroutines.CoroutineContext
import build.buf.gen.accrescent.console.v1.CreateAppDraftResponse as CreateAppDraftResponseProto

/**
* gRPC implementation of the console API's `AppDraftService`.
*
* Calls expect to be intercepted by [CallContextInterceptor].
*
* @param context the coroutine context to run method implementations in.
* @param consoleApi the console API to serve.
*/
class AppDraftServiceImpl(
context: CoroutineContext,
) : AppDraftServiceGrpcKt.AppDraftServiceCoroutineImplBase(context)
private val consoleApi: ConsoleApi,
) : AppDraftServiceGrpcKt.AppDraftServiceCoroutineImplBase(context) {
override suspend fun createAppDraft(
request: CreateAppDraftRequest,
): CreateAppDraftResponseProto {
val context = CallContextInterceptor.CALL_CONTEXT_KEY.get()

return consoleApi.createAppDraft(context, toDomain(request))
.mapLeft(::toStatus)
.getOrElse { throw it.asRuntimeException() }
.let(::toProto)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

package app.accrescent.server.adapters.driving.api.console.grpc

import app.accrescent.server.domain.ports.driving.api.console2.ActiveAppDraftLimitExceededError
import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftError
import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationError
import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationError
import app.accrescent.server.domain.ports.driving.api.console2.GetSelfError
Expand All @@ -12,6 +14,15 @@ import app.accrescent.server.domain.ports.driving.api.console2.ServerError
import app.accrescent.server.domain.ports.driving.api.console2.UnauthenticatedError
import io.grpc.Status

fun toStatus(error: CreateAppDraftError): Status {
return when (error) {
is UnauthenticatedError -> Status.UNAUTHENTICATED
is InsufficientPermissionError -> Status.PERMISSION_DENIED
is ActiveAppDraftLimitExceededError -> Status.RESOURCE_EXHAUSTED
is ServerError -> toStatus(error)
}
}

fun toStatus(error: GetMyOrganizationError): Status {
return when (error) {
is UnauthenticatedError -> Status.UNAUTHENTICATED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,41 @@

package app.accrescent.server.adapters.driving.api.console.grpc

import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftRequest
import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftResponse
import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationResponse
import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationRequest
import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationResponse
import app.accrescent.server.domain.ports.driving.api.console2.GetSelfResponse
import app.accrescent.server.domain.ports.driving.api.console2.UserRole
import build.buf.gen.accrescent.console.v1.createAppDraftResponse
import build.buf.gen.accrescent.console.v1.getMyOrganizationResponse
import build.buf.gen.accrescent.console.v1.getOrganizationResponse
import build.buf.gen.accrescent.console.v1.getSelfResponse
import build.buf.gen.accrescent.console.v1.organization
import build.buf.gen.accrescent.console.v1.user
import build.buf.gen.accrescent.console.v1.CreateAppDraftRequest as CreateAppDraftRequestProto
import build.buf.gen.accrescent.console.v1.CreateAppDraftResponse as CreateAppDraftResponseProto
import build.buf.gen.accrescent.console.v1.GetMyOrganizationResponse as GetMyOrganizationResponseProto
import build.buf.gen.accrescent.console.v1.GetOrganizationRequest as GetOrganizationRequestProto
import build.buf.gen.accrescent.console.v1.GetOrganizationResponse as GetOrganizationResponseProto
import build.buf.gen.accrescent.console.v1.GetSelfResponse as GetSelfResponseProto
import build.buf.gen.accrescent.console.v1.UserRole as UserRoleProto

fun toDomain(request: CreateAppDraftRequestProto): CreateAppDraftRequest {
return CreateAppDraftRequest(request.organizationId)
}

fun toDomain(request: GetOrganizationRequestProto): GetOrganizationRequest {
return GetOrganizationRequest(request.organizationId)
}

fun toProto(response: CreateAppDraftResponse): CreateAppDraftResponseProto {
return createAppDraftResponse {
appDraftId = response.appDraftId
}
}

fun toProto(response: GetMyOrganizationResponse): GetMyOrganizationResponseProto {
return getMyOrganizationResponse {
organizationId = response.organizationId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ private fun createServer(
): GrpcServer {
val options = grpcServerOptions(GrpcProtocol.WEB, WireFormat.PROTOBUF)
val server = GrpcIoServer.server(vertx, options)
.addInterceptedService(AppDraftServiceImpl(context))
.addInterceptedService(AppDraftServiceImpl(context, consoleApi))
.addInterceptedService(AppEditServiceImpl(context))
.addInterceptedService(AppServiceImpl(context))
.addInterceptedService(OrganizationServiceImpl(context, consoleApi))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import arrow.core.Some
* @property value the underlying [Long] representation of this value.
*/
@JvmInline
value class NonNegativeLong private constructor(val value: Long) {
value class NonNegativeLong private constructor(val value: Long) : Comparable<NonNegativeLong> {
companion object {
val MIN_VALUE = NonNegativeLong(0)

Expand All @@ -26,4 +26,8 @@ value class NonNegativeLong private constructor(val value: Long) {
}
}
}

override fun compareTo(other: NonNegativeLong): Int {
return value.compareTo(other.value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@ package app.accrescent.server.domain.api.console2
import app.accrescent.server.core.NonNegativeLong
import app.accrescent.server.core.bindMapLeft
import app.accrescent.server.core.unwrap
import app.accrescent.server.domain.IdGenerator
import app.accrescent.server.domain.IdType
import app.accrescent.server.domain.authn.ExternalUserId
import app.accrescent.server.domain.ports.driven.datastore2.DataStore
import app.accrescent.server.domain.ports.driven.datastore2.HasPermissionRequest
import app.accrescent.server.domain.ports.driven.timestampsource.TimestampSource
import app.accrescent.server.domain.ports.driving.api.console2.ActiveAppDraftLimitExceededError
import app.accrescent.server.domain.ports.driving.api.console2.CallContext
import app.accrescent.server.domain.ports.driving.api.console2.ConsoleApi
import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftError
import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftRequest
import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftResponse
import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationError
import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationResponse
import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationError
Expand All @@ -33,14 +39,50 @@ import arrow.core.raise.ensure
* Primary implementation of [ConsoleApi].
*
* @param dataStore the data store to use for data persistence.
* @param idGenerator the generator of resource IDs.
* @param timestampSource the source of timestamps to use.
* @param adminExternalUserId the external identity of the sole administrator user.
*/
class ConsoleApiImpl(
private val dataStore: DataStore,
private val idGenerator: IdGenerator,
private val timestampSource: TimestampSource,
private val adminExternalUserId: ExternalUserId,
) : ConsoleApi {
override suspend fun createAppDraft(
context: CallContext,
request: CreateAppDraftRequest,
): Either<CreateAppDraftError, CreateAppDraftResponse> = either {
val currentTime = timestampSource.now()
val appDraftId = dataStore.runInTransaction { tx ->
val userId = authenticateCaller(tx, context.sessionId, currentTime)

val hasPermission = tx
.hasPermission(HasPermissionRequest.CreateAppDraft(request.organizationId, userId))
.bindMapLeft(::toServerError)
ensure(hasPermission, ::InsufficientPermissionError)

// App drafts can't be reviewed yet, so all of them are active
val activeAppDraftCount = tx
.countAppDraftsInOrg(request.organizationId)
.bindMapLeft(::toServerError)
ensure(
activeAppDraftCount < ACTIVE_APP_DRAFT_LIMIT,
::ActiveAppDraftLimitExceededError,
)

val appDraftId = idGenerator.generateId(IdType.APP_DRAFT).bindMapLeft(::toServerError)
tx
.createAppDraft(appDraftId, request.organizationId, currentTime)
.bindMapLeft(::toServerError)
appDraftId
}
.bindMapLeft(::toServerError)
.bind()

CreateAppDraftResponse(appDraftId)
}

override suspend fun getMyOrganization(
context: CallContext,
): Either<GetMyOrganizationError, GetMyOrganizationResponse> = either {
Expand Down
Loading
Loading