Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop' into feature/auth
Browse files Browse the repository at this point in the history
  • Loading branch information
bdmendes committed Nov 23, 2022
2 parents edc8ff1 + b43981a commit a3acba4
Show file tree
Hide file tree
Showing 20 changed files with 721 additions and 48 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,6 @@ gradle test
- `dto/` - Data Transfer Objects for creating and modifying entities
- `repository/` - Data access layer methods (Spring Data repositories)
- `service/` - Business logic for the controllers
- `annotations/` - Custom annotations used in the project
- `validation/` - Custom validations used across the different models
- `src/test/` - Self explanatory: unit tests, functional (end-to-end) tests, etc.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies {

tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
freeCompilerArgs = listOf("-Xjsr305=strict", "-Xemit-jvm-type-annotations")
jvmTarget = "17"
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package pt.up.fe.ni.website.backend.annotations.validation

import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import javax.validation.Payload
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [NullOrNotBlankValidator::class])
@MustBeDocumented
annotation class NullOrNotBlank(
val message: String = "must be null or not blank",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<Payload>> = []
)

class NullOrNotBlankValidator : ConstraintValidator<NullOrNotBlank, String?> {
override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
return value == null || value.isNotBlank()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package pt.up.fe.ni.website.backend.controller

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import pt.up.fe.ni.website.backend.model.dto.AccountDto
import pt.up.fe.ni.website.backend.service.AccountService

@RestController
@RequestMapping("/accounts")
class AccountController(private val service: AccountService) {
@GetMapping
fun getAllAccounts() = service.getAllAccounts()

@GetMapping("/{id}")
fun getAccountById(@PathVariable id: Long) = service.getAccountById(id)

@PostMapping("/new")
fun createAccount(@RequestBody dto: AccountDto) = service.createAccount(dto)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pt.up.fe.ni.website.backend.controller

import com.fasterxml.jackson.databind.exc.InvalidFormatException
import com.fasterxml.jackson.databind.exc.MismatchedInputException
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import org.springframework.boot.web.servlet.error.ErrorController
import org.springframework.http.HttpStatus
Expand Down Expand Up @@ -65,6 +66,13 @@ class ErrorController : ErrorController {
param = cause.parameter.name
)
}

is MismatchedInputException -> {
return wrapSimpleError(
"must be ${cause.targetType.simpleName.lowercase()}",
param = cause.path.joinToString(".") { it.fieldName }
)
}
}

return wrapSimpleError(e.message ?: "invalid request body")
Expand All @@ -76,6 +84,19 @@ class ErrorController : ErrorController {
return wrapSimpleError(e.message ?: "element not found")
}

@ExceptionHandler(IllegalArgumentException::class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
fun illegalArgument(e: IllegalArgumentException): CustomError {
return wrapSimpleError(e.message ?: "invalid argument")
}

@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun unexpectedError(e: Exception): CustomError {
System.err.println(e)
return wrapSimpleError("unexpected error: " + e.message)
}

@ExceptionHandler(AccessDeniedException::class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
fun unauthorized(e: AccessDeniedException): CustomError {
Expand All @@ -88,13 +109,6 @@ class ErrorController : ErrorController {
return wrapSimpleError(e.reason ?: (e.message))
}

@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun unexpectedError(e: Exception): CustomError {
System.err.println(e)
return wrapSimpleError("unexpected error: " + e.message)
}

fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError(
mutableListOf(SimpleError(msg, param, value))
)
Expand Down
46 changes: 43 additions & 3 deletions src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,57 @@
package pt.up.fe.ni.website.backend.model

import com.fasterxml.jackson.annotation.JsonProperty
import org.hibernate.validator.constraints.URL
import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank
import java.util.Date
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.ManyToOne
import javax.persistence.JoinColumn
import javax.persistence.OneToMany
import javax.validation.Valid
import javax.validation.constraints.Email
import javax.validation.constraints.NotEmpty
import javax.validation.constraints.Past
import javax.validation.constraints.Size
import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants

@Entity
class Account(
@Column(nullable = false)
val name: String,
@JsonProperty(required = true)
@field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize)
var name: String,

@JsonProperty(required = true)
@Column(unique = true)
@field:NotEmpty
@field:Email
var email: String,

@field:Size(min = Constants.Bio.minSize, max = Constants.Bio.maxSize)
var bio: String?,

@field:Past
var birthDate: Date?,

@field:NullOrNotBlank
@field:URL
var photoPath: String?,

@field:NullOrNotBlank
@field:URL
var linkedin: String?,

@field:NullOrNotBlank
@field:URL
var github: String?,

@JoinColumn
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
val websites: List<@Valid CustomWebsite>,

@Id @GeneratedValue
val id: Long? = null
Expand Down
24 changes: 24 additions & 0 deletions src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package pt.up.fe.ni.website.backend.model

import com.fasterxml.jackson.annotation.JsonProperty
import org.hibernate.validator.constraints.URL
import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.validation.constraints.NotEmpty

@Entity
class CustomWebsite(
@JsonProperty(required = true)
@field:NotEmpty
@field:URL
val url: String,

@field:NullOrNotBlank
@field:URL
val iconPath: String?,

@Id @GeneratedValue
val id: Long? = null
)
4 changes: 4 additions & 0 deletions src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package pt.up.fe.ni.website.backend.model

import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonProperty
import org.hibernate.validator.constraints.URL
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
Expand All @@ -26,12 +28,14 @@ class Post(

@JsonProperty(required = true)
@field:NotEmpty
@field:URL
var thumbnailPath: String,

@CreatedDate
var publishDate: Date? = null,

@LastModifiedDate
@JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss")
var lastUpdatedAt: Date? = null,

@Id @GeneratedValue
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package pt.up.fe.ni.website.backend.model.constants

object AccountConstants {
object Name {
const val minSize = 2
const val maxSize = 100
}

object Bio {
const val minSize = 5
const val maxSize = 500
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package pt.up.fe.ni.website.backend.model.dto

import pt.up.fe.ni.website.backend.model.Account
import java.util.Date

class AccountDto(
val name: String,
val email: String,
val bio: String?,
val birthDate: Date?,
val photoPath: String?,
val linkedin: String?,
val github: String?,
val websites: List<CustomWebsiteDto>
) : Dto<Account>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package pt.up.fe.ni.website.backend.model.dto

import pt.up.fe.ni.website.backend.model.CustomWebsite

class CustomWebsiteDto(
val url: String,
val iconPath: String?
) : Dto<CustomWebsite>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package pt.up.fe.ni.website.backend.repository

import org.springframework.data.repository.CrudRepository
import pt.up.fe.ni.website.backend.model.Account

interface AccountRepository : CrudRepository<Account, Long> {
fun findByEmail(email: String): Account?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package pt.up.fe.ni.website.backend.service

import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import pt.up.fe.ni.website.backend.model.Account
import pt.up.fe.ni.website.backend.model.dto.AccountDto
import pt.up.fe.ni.website.backend.repository.AccountRepository

@Service
class AccountService(private val repository: AccountRepository) {
fun getAllAccounts(): List<Account> = repository.findAll().toList()

fun createAccount(dto: AccountDto): Account {
repository.findByEmail(dto.email)?.let {
throw IllegalArgumentException("email already exists")
}

val account = dto.create()
return repository.save(account)
}

fun getAccountById(id: Long): Account = repository.findByIdOrNull(id)
?: throw NoSuchElementException("account not found with id $id")
}
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ rsa.public-key=classpath:certs/public.pem
# Jackson
spring.jackson.default-property-inclusion=non_null
spring.jackson.deserialization.fail-on-null-creator-properties=true
spring.jackson.date-format=dd-MM-yyyy
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package pt.up.fe.ni.website.backend.annotations.validation

import org.junit.jupiter.api.Test

internal class NullOrNotBlankTest {
@Test
fun `should succeed when null`() {
val validator = NullOrNotBlankValidator()
validator.initialize(NullOrNotBlank())
assert(validator.isValid(null, null))
}

@Test
fun `should succeed when not blank`() {
val validator = NullOrNotBlankValidator()
validator.initialize(NullOrNotBlank())
assert(validator.isValid("not blank", null))
}

@Test
fun `should fail when empty`() {
val validator = NullOrNotBlankValidator()
validator.initialize(NullOrNotBlank())
assert(!validator.isValid("", null))
}

@Test
fun `should fail when blank`() {
val validator = NullOrNotBlankValidator()
validator.initialize(NullOrNotBlank())
assert(!validator.isValid(" ", null))
}
}
Loading

0 comments on commit a3acba4

Please sign in to comment.