Merge branch 'feature/uuid'

This commit is contained in:
Hubert Van De Walle 2020-04-25 19:06:05 +02:00
commit 081e0fca26
17 changed files with 280 additions and 119 deletions

View File

@ -19,7 +19,43 @@ GET http://localhost:8081/notes
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
> {% > {%
client.global.set("uuid", response.body[0].uuid);
client.test("Request executed successfully", function() { client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200"); client.assert(response.status === 200, "Response status is not 200");
}); });
%} %}
### Create note
POST http://localhost:8081/notes
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "test",
"tags": [
"Some",
"Tags"
],
"chapters": [
{
"title": "Chapter 1",
"content": "# This is some content"
}
]
}
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 201, "Response status is not 201");
});
%}
### Get a note
GET http://localhost:8081/notes/{{uuid}}
Authorization: Bearer {{token}}
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
});
%}

View File

@ -0,0 +1,37 @@
-- no need to migrate existing data yet
drop table if exists Chapters;
drop table if exists Tags;
drop table if exists Notes;
CREATE TABLE `Notes`
(
`uuid` binary(16) PRIMARY KEY,
`title` varchar(50) NOT NULL,
`user_id` int NOT NULL,
`updated_at` datetime
);
ALTER TABLE `Notes`
ADD FOREIGN KEY (`user_id`) REFERENCES `Users` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT;
CREATE TABLE `Tags`
(
`id` int PRIMARY KEY AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`note_uuid` binary(16) NOT NULL
);
ALTER TABLE `Tags`
ADD FOREIGN KEY (`note_uuid`) REFERENCES `Notes` (`uuid`) ON DELETE CASCADE ON UPDATE RESTRICT;
CREATE TABLE `Chapters`
(
`id` int PRIMARY KEY AUTO_INCREMENT,
`number` int NOT NULL,
`title` varchar(50) NOT NULL,
`content` text NOT NULL,
`note_uuid` binary(16) NOT NULL
);
ALTER TABLE `Chapters`
ADD FOREIGN KEY (`note_uuid`) REFERENCES `Notes` (`uuid`) ON DELETE CASCADE ON UPDATE RESTRICT;

View File

@ -4,7 +4,7 @@
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<root level="INFO"> <root level="TRACE">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
</root> </root>
<logger name="org.eclipse.jetty" level="INFO"/> <logger name="org.eclipse.jetty" level="INFO"/>

View File

@ -2,11 +2,12 @@ package be.vandewalleh.entities
import me.liuwj.ktorm.entity.* import me.liuwj.ktorm.entity.*
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.*
interface Note : Entity<Note> { interface Note : Entity<Note> {
companion object : Entity.Factory<Note>() companion object : Entity.Factory<Note>()
val id: Int var uuid: UUID
var title: String var title: String
var user: User var user: User
var updatedAt: LocalDateTime var updatedAt: LocalDateTime

View File

@ -1,6 +1,8 @@
package be.vandewalleh.extensions package be.vandewalleh.extensions
import be.vandewalleh.kodein import be.vandewalleh.kodein
import be.vandewalleh.services.FullNoteCreateDTO
import be.vandewalleh.services.FullNotePatchDTO
import be.vandewalleh.services.UserService import be.vandewalleh.services.UserService
import io.ktor.application.* import io.ktor.application.*
import io.ktor.auth.* import io.ktor.auth.*
@ -23,12 +25,8 @@ fun ApplicationCall.userId(): Int {
return userService.getUserId(email)!! return userService.getUserId(email)!!
} }
private class Tags(val tags: List<String>) class NoteCreate(val title: String, val tags: List<String>)
suspend fun ApplicationCall.receiveTags(): List<String> { suspend fun ApplicationCall.receiveNoteCreate(): FullNoteCreateDTO = receive()
return receive<Tags>().tags
}
data class NotePatch(val tags: List<String>?, val title: String?) suspend fun ApplicationCall.receiveNotePatch() : FullNotePatchDTO = receive()
suspend fun ApplicationCall.receiveNotePatch() = receive<NotePatch>()

View File

@ -0,0 +1,27 @@
package be.vandewalleh.extensions
import me.liuwj.ktorm.schema.*
import java.nio.ByteBuffer
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
import java.util.*
class UuidBinarySqlType : SqlType<java.util.UUID>(Types.BINARY, typeName = "uuidBinary") {
override fun doGetResult(rs: ResultSet, index: Int): UUID? {
val value = rs.getBytes(index) ?: return null
return ByteBuffer.wrap(value).let { b -> UUID(b.long, b.long) }
}
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UUID) {
val bytes = ByteBuffer.allocate(16)
.putLong(parameter.mostSignificantBits)
.putLong(parameter.leastSignificantBits)
.array()
ps.setBytes(index, bytes)
}
}
fun <E : Any> BaseTable<E>.uuidBinary(name: String): BaseTable<E>.ColumnRegistration<java.util.UUID> {
return registerColumn(name, UuidBinarySqlType())
}

View File

@ -1,22 +1,12 @@
package be.vandewalleh.extensions package be.vandewalleh.extensions
import be.vandewalleh.kodein
import be.vandewalleh.services.NotesService
import be.vandewalleh.tables.Notes
import io.ktor.http.* import io.ktor.http.*
import org.kodein.di.generic.instance import java.util.*
private val notesService by kodein.instance<NotesService>()
fun Parameters.noteTitle(): String { fun Parameters.noteTitle(): String {
return this["noteTitle"]!! return this["noteTitle"]!!
} }
/** fun Parameters.noteUuid(): UUID {
* Method that returns a [Notes] ID from it's title and the currently logged in user. return UUID.fromString(this["noteUuid"])
* returns null if none found
*/
fun Parameters.noteId(userId: Int): Int? {
val title = noteTitle()
return notesService.getNoteIdFromUserIdAndTitle(userId, title)
} }

View File

@ -9,5 +9,6 @@ fun Application.corsFeature() {
anyHost() anyHost()
header(HttpHeaders.ContentType) header(HttpHeaders.ContentType)
header(HttpHeaders.Authorization) header(HttpHeaders.Authorization)
methods.add(HttpMethod.Delete)
} }
} }

View File

@ -1,13 +0,0 @@
package be.vandewalleh.routing
import io.ktor.auth.*
import io.ktor.routing.*
import org.kodein.di.Kodein
fun Routing.chapters(kodein: Kodein) {
authenticate {
route("/notes/{noteTitle}/chapters/{chapterNumber}") {
}
}
}

View File

@ -1,9 +1,11 @@
package be.vandewalleh.routing package be.vandewalleh.routing
import be.vandewalleh.extensions.receiveNoteCreate
import be.vandewalleh.extensions.userId import be.vandewalleh.extensions.userId
import be.vandewalleh.services.NotesService import be.vandewalleh.services.NotesService
import io.ktor.application.* import io.ktor.application.*
import io.ktor.auth.* import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.response.* import io.ktor.response.*
import io.ktor.routing.* import io.ktor.routing.*
import org.kodein.di.Kodein import org.kodein.di.Kodein
@ -18,5 +20,12 @@ fun Routing.notes(kodein: Kodein) {
val notes = notesService.getNotes(userId) val notes = notesService.getNotes(userId)
call.respond(notes) call.respond(notes)
} }
post("/notes") {
val userId = call.userId()
val note = call.receiveNoteCreate()
val uuid = notesService.createNote(userId, note)
call.respond(HttpStatusCode.Created, mapOf("uuid" to uuid))
}
} }
} }

View File

@ -8,6 +8,5 @@ fun Routing.registerRoutes(kodein: Kodein) {
login(kodein) login(kodein)
notes(kodein) notes(kodein)
title(kodein) title(kodein)
chapters(kodein)
tags(kodein) tags(kodein)
} }

View File

@ -1,6 +1,9 @@
package be.vandewalleh.routing package be.vandewalleh.routing
import be.vandewalleh.extensions.* import be.vandewalleh.extensions.noteUuid
import be.vandewalleh.extensions.receiveNotePatch
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.extensions.userId
import be.vandewalleh.services.NotesService import be.vandewalleh.services.NotesService
import io.ktor.application.* import io.ktor.application.*
import io.ktor.auth.* import io.ktor.auth.*
@ -14,49 +17,39 @@ fun Routing.title(kodein: Kodein) {
val notesService by kodein.instance<NotesService>() val notesService by kodein.instance<NotesService>()
authenticate { authenticate {
route("/notes/{noteTitle}") { route("/notes/{noteUuid}") {
post {
val userId = call.userId()
val title = call.parameters.noteTitle()
val tags = call.receiveTags()
val noteId = call.parameters.noteId(userId)
if (noteId != null) {
return@post call.respondStatus(HttpStatusCode.Conflict)
}
notesService.createNote(userId, title, tags)
call.respondStatus(HttpStatusCode.Created)
}
get { get {
val userId = call.userId() val userId = call.userId()
val noteId = call.parameters.noteId(userId) val noteUuid = call.parameters.noteUuid()
?: return@get call.respondStatus(HttpStatusCode.NotFound)
val response = notesService.getTagsAndChapters(noteId) val exists = notesService.noteExists(userId, noteUuid)
if (!exists) return@get call.respondStatus(HttpStatusCode.NotFound)
val response = notesService.getNote(noteUuid)
call.respond(response) call.respond(response)
} }
patch { patch {
val notePatch = call.receiveNotePatch()
if (notePatch.tags == null && notePatch.title == null)
return@patch call.respondStatus(HttpStatusCode.BadRequest)
val userId = call.userId() val userId = call.userId()
val noteId = call.parameters.noteId(userId) val noteUuid = call.parameters.noteUuid()
?: return@patch call.respondStatus(HttpStatusCode.NotFound)
notesService.updateNote(noteId, notePatch.tags, notePatch.title) val exists = notesService.noteExists(userId, noteUuid)
if (!exists) return@patch call.respondStatus(HttpStatusCode.NotFound)
val notePatch = call.receiveNotePatch().copy(uuid = noteUuid)
notesService.updateNote(notePatch)
call.respondStatus(HttpStatusCode.OK) call.respondStatus(HttpStatusCode.OK)
} }
delete { delete {
val userId = call.userId() val userId = call.userId()
val noteId = call.parameters.noteId(userId) val noteUuid = call.parameters.noteUuid()
?: return@delete call.respondStatus(HttpStatusCode.NotFound)
notesService.deleteNote(noteId) val exists = notesService.noteExists(userId, noteUuid)
if (!exists) return@delete call.respondStatus(HttpStatusCode.NotFound)
notesService.deleteNote(noteUuid)
call.respondStatus(HttpStatusCode.OK) call.respondStatus(HttpStatusCode.OK)
} }
} }

View File

@ -5,11 +5,13 @@ import be.vandewalleh.tables.Notes
import be.vandewalleh.tables.Tags import be.vandewalleh.tables.Tags
import me.liuwj.ktorm.database.* import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.* import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import org.kodein.di.Kodein import org.kodein.di.Kodein
import org.kodein.di.KodeinAware import org.kodein.di.KodeinAware
import org.kodein.di.generic.instance import org.kodein.di.generic.instance
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.*
/** /**
* service to handle database queries at the Notes level. * service to handle database queries at the Notes level.
@ -18,102 +20,175 @@ class NotesService(override val kodein: Kodein) : KodeinAware {
private val db by instance<Database>() private val db by instance<Database>()
/** /**
* returns a list of [NotesDTO] associated with the userId * returns a list of [BasicNoteDTO] associated with the userId
*/ */
fun getNotes(userId: Int): List<NotesDTO> = db.from(Notes) fun getNotes(userId: Int): List<BasicNoteDTO> {
.select(Notes.id, Notes.title, Notes.updatedAt) val notes = db.sequenceOf(Notes)
.where { Notes.userId eq userId } .filterColumns { listOf(it.uuid, it.title, it.updatedAt) }
.orderBy(Notes.updatedAt.desc()) .filter { it.userId eq userId }
.map { row -> .sortedByDescending { it.updatedAt }
val tags = db.from(Tags) .toList()
.select(Tags.name)
.where { Tags.noteId eq row[Notes.id]!! }
.map { it[Tags.name]!! }
val updatedAt = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(row[Notes.updatedAt]!!) if (notes.isEmpty()) return emptyList()
NotesDTO(row[Notes.title]!!, tags, updatedAt) val tags = db.sequenceOf(Tags)
.filterColumns { listOf(it.noteUuid, it.name) }
.filter { it.noteUuid inList notes.map { it.uuid } }
.toList()
return notes.map { note ->
val noteTags = tags.asSequence()
.filter { it.note.uuid == note.uuid }
.map { it.name }
.toList()
BasicNoteDTO(note.uuid, note.title, noteTags, DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(note.updatedAt))
} }
}
fun getNoteIdFromUserIdAndTitle(userId: Int, noteTitle: String): Int? = db.from(Notes) fun noteExists(userId: Int, uuid: UUID): Boolean {
.select(Notes.id) return db.from(Notes)
.where { Notes.userId eq userId and (Notes.title eq noteTitle) } .select(Notes.uuid)
.limit(0, 1) .where { Notes.userId eq userId }
.map { it[Notes.id]!! } .where { Notes.uuid eq uuid }
.firstOrNull() .limit(0, 1)
.toList().size == 1
}
fun createNote(userId: Int, title: String, tags: List<String>) { fun createNote(userId: Int, note: FullNoteCreateDTO): UUID {
val uuid = UUID.randomUUID()
db.useTransaction { db.useTransaction {
val noteId = db.insertAndGenerateKey(Notes) { db.insert(Notes) {
it.title to title it.uuid to uuid
it.title to note.title
it.userId to userId it.userId to userId
it.updatedAt to LocalDateTime.now() it.updatedAt to LocalDateTime.now()
} }
tags.forEach { tagName -> db.batchInsert(Tags) {
db.insert(Tags) { note.tags.forEach { tagName ->
it.name to tagName item {
it.noteId to noteId it.noteUuid to uuid
it.name to tagName
}
} }
} }
db.batchInsert(Chapters) {
note.chapters.forEachIndexed { index, chapter ->
item {
it.noteUuid to uuid
it.title to chapter.title
it.number to index
it.content to chapter.content
}
}
}
} }
return uuid
} }
fun getTagsAndChapters(noteId: Int): TagsChaptersDTO { fun getNote(noteUuid: UUID): FullNoteDTO {
val note = db.sequenceOf(Notes)
.filterColumns { listOf(it.title, it.updatedAt) }
.find { it.uuid eq noteUuid } ?: error("Note not found")
val tags = db.from(Tags) val tags = db.from(Tags)
.select(Tags.name) .select(Tags.name)
.where { Tags.noteId eq noteId } .where { Tags.noteUuid eq noteUuid }
.map { it[Tags.name]!! } .map { it[Tags.name]!! }
.toList() .toList()
val chapters = db.from(Chapters) val chapters = db.from(Chapters)
.select(Chapters.title, Chapters.content) .select(Chapters.title, Chapters.content)
.where { Chapters.noteId eq noteId } .where { Chapters.noteUuid eq noteUuid }
.orderBy(Chapters.number.asc()) .orderBy(Chapters.number.asc())
.map { ChaptersDTO(it[Chapters.title]!!, it[Chapters.content]!!) } .map { ChapterDTO(it[Chapters.title]!!, it[Chapters.content]!!) }
.toList() .toList()
return TagsChaptersDTO(tags, chapters) val updatedAtFormatted = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(note.updatedAt)
return FullNoteDTO(
uuid = noteUuid,
title = note.title,
updatedAt = updatedAtFormatted,
tags = tags,
chapters = chapters
)
} }
fun updateNote(noteId: Int, tags: List<String>?, title: String?): Unit = fun updateNote(patch: FullNotePatchDTO) {
if (patch.uuid == null) return
db.useTransaction { db.useTransaction {
if (title != null) { if (patch.title != null) {
db.update(Notes) { db.update(Notes) {
it.title to title it.title to patch.title
it.updatedAt to LocalDateTime.now() it.updatedAt to LocalDateTime.now()
where { it.id eq noteId } where { it.uuid eq patch.uuid }
} }
} }
if (tags != null) { if (patch.tags != null) {
// delete all tags // delete all tags
db.delete(Tags) { db.delete(Tags) {
it.noteId eq noteId it.noteUuid eq patch.uuid
} }
// put new ones // put new ones
tags.forEach { tagName -> patch.tags.forEach { tagName ->
db.insert(Tags) { db.insert(Tags) {
it.name to tagName it.name to tagName
it.noteId to noteId it.noteUuid to patch.uuid
} }
} }
} }
} }
fun deleteNote(noteId: Int): Unit = TODO("get chapters")
}
fun deleteNote(noteUuid: UUID): Unit =
db.useTransaction { db.useTransaction {
db.delete(Notes) { it.id eq noteId } db.delete(Notes) { it.uuid eq noteUuid }
} }
fun getTags(userId: Int): List<String> = db.from(Tags) fun getTags(userId: Int): List<String> = db.from(Tags)
.leftJoin(Notes, on = Tags.noteId eq Notes.id) .leftJoin(Notes, on = Tags.noteUuid eq Notes.uuid)
.select(Tags.name) .select(Tags.name)
.where { Notes.userId eq userId } .where { Notes.userId eq userId }
.map { it[Tags.name]!! } .map { it[Tags.name]!! }
} }
data class ChaptersDTO(val title: String, val content: String) data class ChapterDTO(
data class TagsChaptersDTO(val tags: List<String>, val chapters: List<ChaptersDTO>) val title: String,
data class NotesDTO(val title: String, val tags: List<String>, val updatedAt: String) val content: String
)
data class FullNoteDTO(
val uuid: UUID,
val title: String,
val updatedAt: String,
val tags: List<String>,
val chapters: List<ChapterDTO>
)
data class FullNoteCreateDTO(
val title: String,
val tags: List<String>,
val chapters: List<ChapterDTO>
)
data class FullNotePatchDTO(
val uuid: UUID? = null,
val title: String? = null,
val updatedAt: String? = null,
val tags: List<String>? = null,
val chapters: List<ChapterDTO>? = null
)
data class BasicNoteDTO(
val uuid: UUID,
val title: String,
val tags: List<String>,
val updatedAt: String
)

View File

@ -1,6 +1,7 @@
package be.vandewalleh.tables package be.vandewalleh.tables
import be.vandewalleh.entities.Chapter import be.vandewalleh.entities.Chapter
import be.vandewalleh.extensions.uuidBinary
import me.liuwj.ktorm.schema.* import me.liuwj.ktorm.schema.*
object Chapters : Table<Chapter>("Chapters") { object Chapters : Table<Chapter>("Chapters") {
@ -8,6 +9,6 @@ object Chapters : Table<Chapter>("Chapters") {
val number by int("number").bindTo { it.number } val number by int("number").bindTo { it.number }
val content by text("content").bindTo { it.content } val content by text("content").bindTo { it.content }
val title by varchar("title").bindTo { it.title } val title by varchar("title").bindTo { it.title }
val noteId by int("note_id").references(Notes) { it.note } val noteUuid by uuidBinary("note_uuid").references(Notes) { it.note }
val note get() = noteId.referenceTable as Notes val note get() = noteUuid.referenceTable as Notes
} }

View File

@ -1,10 +1,11 @@
package be.vandewalleh.tables package be.vandewalleh.tables
import be.vandewalleh.entities.Note import be.vandewalleh.entities.Note
import be.vandewalleh.extensions.uuidBinary
import me.liuwj.ktorm.schema.* import me.liuwj.ktorm.schema.*
object Notes : Table<Note>("Notes") { object Notes : Table<Note>("Notes") {
val id by int("id").primaryKey().bindTo { it.id } val uuid by uuidBinary("uuid").primaryKey().bindTo { it.uuid }
val title by varchar("title").bindTo { it.title } val title by varchar("title").bindTo { it.title }
val userId by int("user_id").references(Users) { it.user } val userId by int("user_id").references(Users) { it.user }
val updatedAt by datetime("updated_at").bindTo { it.updatedAt } val updatedAt by datetime("updated_at").bindTo { it.updatedAt }

View File

@ -1,11 +1,12 @@
package be.vandewalleh.tables package be.vandewalleh.tables
import be.vandewalleh.entities.Tag import be.vandewalleh.entities.Tag
import be.vandewalleh.extensions.uuidBinary
import me.liuwj.ktorm.schema.* import me.liuwj.ktorm.schema.*
object Tags : Table<Tag>("Tags") { object Tags : Table<Tag>("Tags") {
val id by int("id").primaryKey().bindTo { it.id } val id by int("id").primaryKey().bindTo { it.id }
val name by varchar("name").bindTo { it.name } val name by varchar("name").bindTo { it.name }
val noteId by int("note_id").references(Notes) { it.note } val noteUuid by uuidBinary("note_uuid").references(Notes) { it.note }
val note get() = noteId.referenceTable as Notes val note get() = noteUuid.referenceTable as Notes
} }

View File

@ -70,14 +70,19 @@ export default {
], ],
}), }),
mounted() { mounted() {
this.$axios.get('/notes').then((e) => { this.loadNotes()
this.notes = e.data
this.loading = false
})
}, },
methods: { methods: {
async loadNotes() {
await this.$axios.get('/notes').then((e) => {
this.notes = e.data
this.loading = false
})
},
editItem(item) {}, editItem(item) {},
deleteItem(item) {}, async deleteItem(item) {
await this.$axios.delete(`/notes/${item.uuid}`).then(this.loadNotes)
},
format, format,
}, },
} }