Skip to content

Commit 47c3e75

Browse files
authored
Add reactive app 4 binary files module (#14)
* Remove useless DTOs. * Remove content from DTO. * Add reactive-app-4-binary-files module.
1 parent c193823 commit 47c3e75

File tree

20 files changed

+458
-36
lines changed

20 files changed

+458
-36
lines changed

.github/workflows/tests.yml

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,55 @@ jobs:
115115
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f apps/reactive-app spring-boot:start
116116
- run: http --ignore-stdin get :8080
117117
- run: http --ignore-stdin -f post :8080/upload file@README.md
118-
- run: mkdir -p target ; id=$(http --ignore-stdin get :8080 | jq '.[0].id') ; http get :8080/download/$id > target/index.md
119-
- run: ls -lah target/ ; cat > target/index.md
118+
- name: Set output JOB_ID variable
119+
run: echo "::set-output name=JOB_ID::$(http --ignore-stdin get :8080 | jq '.[0].id')"
120+
id: refs_names
121+
shell: bash
122+
- run: mkdir -p $GITHUB_WORKSPACE/target ; http get :8080/download/${{ steps.refs_names.outputs.JOB_ID }} > target/index.md
123+
- run: mkdir -p $GITHUB_WORKSPACE/target ; id=$(http --ignore-stdin get :8080 | jq '.[0].id') ; http get :8080/download/$id > target/index.md
124+
- run: ls -lah $GITHUB_WORKSPACE/target/
125+
- run: cat > $GITHUB_WORKSPACE/target/index.md
120126
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f apps/reactive-app spring-boot:stop
121127
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f docker -P down
128+
reactive-app-4-binary-files-integration-tests:
129+
name: reactive-app-4-binary-files integration tests java-${{ matrix.java }}
130+
if: github.event.inputs.trigger == ''
131+
|| !startsWith(github.event.inputs.trigger, 'm')
132+
|| !startsWith(github.event.inputs.trigger, 'M')
133+
strategy:
134+
matrix:
135+
java: [11]
136+
runs-on: ubuntu-latest
137+
steps:
138+
- uses: actions/checkout@v1
139+
- uses: actions/setup-java@v2
140+
with:
141+
distribution: 'temurin'
142+
java-version: ${{ matrix.java }}
143+
- uses: actions/cache@v3
144+
with:
145+
path: |
146+
~/.m2
147+
~/.npm
148+
~/.docker
149+
~/.gradle
150+
key: ${{ runner.os }}-build-${{ hashFiles('**/*gradle*', '**/pom.xml') }}
151+
- run: sudo apt install -y httpie
152+
- run: docker pull mysql:8.0.24
153+
- run: if [[ "" != `docker ps -aq` ]] ; then docker rm -f -v `docker ps -aq` ; fi
154+
- run: ./mvnw -f docker -P down ; ./mvnw -f docker -P up
155+
- run: while [[ $(docker ps -n 1 -q -f health=healthy -f status=running | wc -l) -lt 1 ]] ; do sleep 3 ; echo -n '.' ; done ; sleep 30 ; echo 'MySQL is ready.'
156+
- run: ./mvnw -f apps/reactive-app-4-binary-files clean compile liquibase:update
157+
-Dliquibase.url='jdbc:mysql://127.0.0.1:3306/database'
158+
-Dliquibase.username=user -Dliquibase.password=password
159+
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f apps/reactive-app-4-binary-files
160+
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f apps/reactive-app-4-binary-files spring-boot:start
161+
- run: http --ignore-stdin get :8004
162+
- run: http --ignore-stdin -f post :8004/upload file@README.md
163+
- run: mkdir -p target ; id=$(http --ignore-stdin get :8004 | jq '.[0].id') ; http get :8004/download/$id > target/index.md
164+
- run: ls -lah target/ ; cat > target/index.md
165+
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f apps/reactive-app-4-binary-files spring-boot:stop
166+
- run: cd $GITHUB_WORKSPACE ; ./mvnw -f docker -P down
122167
reactive-app-3-download-file-integration-tests:
123168
name: reactive-app-3-download-file integration tests java-${{ matrix.java }}
124169
if: github.event.inputs.trigger == ''

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,42 @@ cat target/index.md
349349
docker rm -f -v `docker ps -aq`
350350
```
351351

352+
### reactive-app-4-binary-files
353+
354+
#### test and build
355+
356+
```bash
357+
./mvnw
358+
```
359+
360+
#### run and verify
361+
362+
```bash
363+
if [[ "" != `docker ps -aq` ]] ; then docker rm -f -v `docker ps -aq` ; fi
364+
./mvnw -f docker -P down ; ./mvnw -f docker -P up ; ./mvnw -f docker -P logs &
365+
366+
while [[ $(docker ps -n 1 -q -f health=healthy -f status=running | wc -l) -lt 1 ]] ; do sleep 3 ; echo -n '.' ; done ; sleep 15; echo 'MySQL is ready.'
367+
./mvnw -f apps/reactive-app clean compile \
368+
liquibase:update \
369+
-Dliquibase.url='jdbc:mysql://127.0.0.1:3306/database' \
370+
-Dliquibase.username=user \
371+
-Dliquibase.password=password
372+
373+
./mvnw -f apps/reactive-app-4-binary-files compile spring-boot:start
374+
375+
http get :8004
376+
http --form --multipart --boundary=xoxo post :8004/upload file@README.md
377+
378+
mkdir target
379+
id=$(http get :8004 | jq '.[0].id')
380+
http get :8004/download/$id > target/index.md
381+
382+
cat target/index.md
383+
384+
./mvnw -f apps/reactive-app-4-binary-files spring-boot:stop
385+
docker rm -f -v `docker ps -aq`
386+
```
387+
352388
### reactive-app
353389

354390
#### test and build

apps/reactive-app-2-upload-file/src/main/kotlin/daggerok/app/api.kt

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,6 @@ package daggerok.app
33
import com.fasterxml.jackson.annotation.JsonIgnore
44
import java.time.Instant
55

6-
data class ReportItemDTO(
7-
val id: Long? = null,
8-
val jobId: Long = -1,
9-
val name: String = "",
10-
val content: String = "",
11-
val lastModifiedAt: Instant? = null,
12-
)
13-
146
data class ReportItemDocument(
157
val id: Long? = null,
168
@JsonIgnore val jobId: Long = -1,

apps/reactive-app-3-download-file/src/main/kotlin/daggerok/app/api.kt

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,6 @@ package daggerok.app
33
import com.fasterxml.jackson.annotation.JsonIgnore
44
import java.time.Instant
55

6-
data class ReportItemDTO(
7-
val id: Long? = null,
8-
val jobId: Long = -1,
9-
val name: String = "",
10-
val content: String = "",
11-
val lastModifiedAt: Instant? = null,
12-
)
13-
146
data class ReportItemDocument(
157
val id: Long? = null,
168
@JsonIgnore val jobId: Long = -1,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<parent>
6+
<artifactId>upload-download-files-as-mysql-blobs-with-spring</artifactId>
7+
<groupId>daggerok</groupId>
8+
<version>0.0.1-SNAPSHOT</version>
9+
<relativePath>../../pom.xml</relativePath>
10+
</parent>
11+
<modelVersion>4.0.0</modelVersion>
12+
<artifactId>reactive-app-4-binary-files</artifactId>
13+
<packaging>jar</packaging>
14+
<dependencies>
15+
<dependency>
16+
<groupId>org.springframework.boot</groupId>
17+
<artifactId>spring-boot-configuration-processor</artifactId>
18+
<optional>true</optional>
19+
</dependency>
20+
<!-- -->
21+
<dependency>
22+
<groupId>org.springframework.boot</groupId>
23+
<artifactId>spring-boot-starter-webflux</artifactId>
24+
</dependency>
25+
<!-- -->
26+
<dependency>
27+
<groupId>org.springframework.boot</groupId>
28+
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
29+
</dependency>
30+
<dependency>
31+
<groupId>dev.miku</groupId>
32+
<artifactId>r2dbc-mysql</artifactId>
33+
<scope>runtime</scope>
34+
</dependency>
35+
<dependency>
36+
<groupId>mysql</groupId>
37+
<artifactId>mysql-connector-java</artifactId>
38+
<scope>runtime</scope>
39+
</dependency>
40+
<!-- -->
41+
<dependency>
42+
<groupId>io.projectreactor</groupId>
43+
<artifactId>reactor-test</artifactId>
44+
<scope>test</scope>
45+
</dependency>
46+
</dependencies>
47+
<build>
48+
<plugins>
49+
<plugin>
50+
<groupId>org.springframework.boot</groupId>
51+
<artifactId>spring-boot-maven-plugin</artifactId>
52+
<configuration>
53+
<jmxPort>9004</jmxPort>
54+
</configuration>
55+
</plugin>
56+
</plugins>
57+
</build>
58+
</project>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package daggerok
2+
3+
import java.time.ZoneId
4+
import java.time.ZoneOffset
5+
import java.util.Locale
6+
import java.util.TimeZone
7+
import org.springframework.boot.autoconfigure.SpringBootApplication
8+
import org.springframework.boot.runApplication
9+
10+
@SpringBootApplication
11+
class ReactiveApp
12+
13+
fun main(args: Array<String>) {
14+
runApplication<ReactiveApp>(*args) {
15+
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.from(ZoneOffset.UTC)))
16+
Locale.setDefault(Locale.US)
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package daggerok.app
2+
3+
import java.time.Instant
4+
import org.springframework.data.annotation.CreatedDate
5+
import org.springframework.data.annotation.Id
6+
import org.springframework.data.annotation.LastModifiedDate
7+
import org.springframework.data.relational.core.mapping.Table
8+
import org.springframework.format.annotation.DateTimeFormat
9+
import org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME
10+
11+
@Table("report_items")
12+
data class ReportItem(
13+
@Id val id: Long? = null,
14+
val jobId: Long = -1,
15+
val name: String = "",
16+
@Suppress("ArrayInDataClass") val content: ByteArray = ByteArray(0),
17+
@CreatedDate @LastModifiedDate @DateTimeFormat(iso = DATE_TIME) val lastModifiedAt: Instant? = Instant.now(),
18+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package daggerok.app
2+
3+
import org.springframework.data.domain.Sort
4+
import org.springframework.data.repository.query.Param
5+
import org.springframework.data.repository.reactive.ReactiveCrudRepository
6+
import reactor.core.publisher.Flux
7+
import reactor.core.publisher.Mono
8+
9+
interface ReportItems : ReactiveCrudRepository<ReportItem, Long> {
10+
fun findFirstByName(@Param("name") filename: String): Mono<ReportItem>
11+
fun findAllByNameContaining(@Param("name") filename: String, sort: Sort = Sort.by("lastModifiedAt", "id").descending()): Flux<ReportItem>
12+
fun findAll(sort: Sort = Sort.by("lastModifiedAt", "id").descending()): Flux<ReportItem>
13+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package daggerok.app
2+
3+
import org.reactivestreams.Publisher
4+
import org.springframework.core.io.buffer.DataBuffer
5+
import org.springframework.core.io.buffer.DataBufferUtils
6+
import org.springframework.data.domain.Sort
7+
import org.springframework.http.HttpHeaders
8+
import org.springframework.http.MediaType
9+
import org.springframework.http.ZeroCopyHttpOutputMessage
10+
import org.springframework.http.codec.multipart.FilePart
11+
import org.springframework.http.server.reactive.ServerHttpResponse
12+
import org.springframework.transaction.annotation.Transactional
13+
import org.springframework.web.bind.annotation.GetMapping
14+
import org.springframework.web.bind.annotation.PathVariable
15+
import org.springframework.web.bind.annotation.PostMapping
16+
import org.springframework.web.bind.annotation.RequestParam
17+
import org.springframework.web.bind.annotation.RequestPart
18+
import org.springframework.web.bind.annotation.RestController
19+
import reactor.core.publisher.Flux
20+
import reactor.core.publisher.Mono
21+
import reactor.kotlin.core.publisher.toMono
22+
23+
@RestController
24+
@Transactional(readOnly = true)
25+
data class ReportItemsResource(private val reportItems: ReportItems) {
26+
27+
@GetMapping("/")
28+
fun getUploads(@RequestParam("id", required = false, defaultValue = "") ids: List<Long>,
29+
@RequestParam("filename", required = false, defaultValue = "") filenames: List<String>): Publisher<ReportItemDocument> =
30+
when {
31+
ids.isNotEmpty() ->
32+
Flux.fromIterable(ids)
33+
.flatMap { reportItems.findById(it) }
34+
.map { it.toDocument() }
35+
.switchIfEmpty(Mono.error(MissingFileException()))
36+
filenames.isNotEmpty() ->
37+
Flux.fromIterable(filenames)
38+
.filter(String::isNotBlank)
39+
.flatMap { reportItems.findAllByNameContaining(it) }
40+
.map(ReportItem::toDocument)
41+
else ->
42+
reportItems.findAll(Sort.by("lastModifiedAt", "id").descending())
43+
.map(ReportItem::toDocument)
44+
}
45+
46+
// @PostMapping("/upload")
47+
// @Transactional(readOnly = false)
48+
// fun uploadFile(@RequestPart("file") file: FilePart) =
49+
// Mono.from(file.content())
50+
// .map {
51+
// ByteArray(it.readableByteCount()).apply {
52+
// it.read(this)
53+
// DataBufferUtils.release(it)
54+
// }
55+
// }
56+
// .map { ReportItem(name = file.filename(), content = it) }
57+
// .flatMap(reportItems::save)
58+
// .map { UploadFileDocument(id = it.id, filename = it.name) }
59+
60+
@PostMapping("/upload")
61+
@Transactional(readOnly = false)
62+
fun uploadFile(@RequestPart("file") fileStream: Flux<FilePart>): Mono<ReportItemDocument> =
63+
fileStream.map { it.filename() to it.content() }
64+
.flatMap { (filename, contentStream) ->
65+
contentStream
66+
.map {
67+
ByteArray(it.readableByteCount()).apply {
68+
it.read(this)
69+
DataBufferUtils.release(it)
70+
}
71+
}
72+
.map { ReportItem(name = filename, content = it) }
73+
.flatMap(reportItems::save)
74+
}
75+
.map { it.toDocument() }
76+
.toMono()
77+
78+
@GetMapping("/download/{id}") // may be post as well
79+
fun downloadFile(@PathVariable("id") id: Long, response: ServerHttpResponse): Mono<Void> =
80+
reportItems.findById(id)
81+
.map {
82+
val dataBuffer: DataBuffer = response.bufferFactory().allocateBuffer(it.content.size)
83+
it.name to dataBuffer.write(it.content)
84+
}
85+
.flatMap { (filename, dataBuffer) ->
86+
val zeroCopyResponse = response as ZeroCopyHttpOutputMessage
87+
zeroCopyResponse.headers[HttpHeaders.CONTENT_DISPOSITION] = "attachment; filename=${filename}"
88+
zeroCopyResponse.headers[HttpHeaders.CONTENT_TYPE] = MediaType.APPLICATION_OCTET_STREAM_VALUE
89+
zeroCopyResponse.writeWith(Mono.just(dataBuffer))
90+
}
91+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package daggerok.app
2+
3+
import org.springframework.boot.web.error.ErrorAttributeOptions
4+
import org.springframework.boot.web.error.ErrorAttributeOptions.Include.MESSAGE
5+
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes
6+
import org.springframework.stereotype.Component
7+
import org.springframework.web.reactive.function.server.ServerRequest
8+
9+
/**
10+
* This component customizes error response.
11+
*
12+
* Adds `api` map with supported endpoints
13+
*/
14+
@Component
15+
class RestApiErrorAttributes : DefaultErrorAttributes() {
16+
17+
override fun getErrorAttributes(request: ServerRequest?, options: ErrorAttributeOptions): MutableMap<String, Any> =
18+
super.getErrorAttributes(request, options.including(MESSAGE)).apply {
19+
val baseUrl = request?.uri()?.let { "${it.scheme}://${it.authority}" } ?: ""
20+
val api = mapOf(
21+
"Upload file => POST" to "$baseUrl/upload",
22+
"List saved upload entities => GET" to baseUrl,
23+
"Download file => GET" to "$baseUrl/download/{id}",
24+
)
25+
put("api", api)
26+
}
27+
}

0 commit comments

Comments
 (0)