Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers.batch

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.component.ProjectLastModifiedManager
import io.tolgee.constants.Message
import io.tolgee.dtos.request.export.ExportParams
import io.tolgee.exceptions.BadRequestException
Expand All @@ -27,6 +28,7 @@ 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 org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.io.InputStream
import java.io.OutputStream
Expand All @@ -45,38 +47,85 @@ class V2ExportController(
private val languageService: LanguageService,
private val authenticationFacade: AuthenticationFacade,
private val streamingResponseBodyProvider: StreamingResponseBodyProvider,
private val projectLastModifiedManager: ProjectLastModifiedManager,
) {
@GetMapping(value = [""])
@Operation(summary = "Export data")
@Operation(
summary = "Export data",
description = """
Exports project data in various formats (JSON, properties, YAML, etc.).

## HTTP Conditional Requests Support

This endpoint supports HTTP conditional requests using the If-Modified-Since header:

- **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date
- **Data not modified**: Returns HTTP 304 Not Modified with empty body
- **Data modified or no header**: Returns HTTP 200 OK with the exported data and Last-Modified header

The Last-Modified header in the response contains the timestamp of the last project modification,
which can be used for subsequent conditional requests to avoid unnecessary data transfer when the
project hasn't changed.

Cache-Control header is set to max-age=0 to ensure validation on each request.
"""
)
@RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW])
@AllowApiAccess
@ExportApiResponse
fun exportData(
@ParameterObject params: ExportParams,
): ResponseEntity<StreamingResponseBody> {
params.languages =
languageService
.getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id)
.toList()
.map { language -> language.tag }
.toSet()
val exported = exportService.export(projectHolder.project.id, params)
checkExportNotEmpty(exported)
return getExportResponse(params, exported)
request: WebRequest
): ResponseEntity<StreamingResponseBody>? {
return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder ->
params.languages =
languageService
.getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id)
.toList()
.map { language -> language.tag }
.toSet()
val exported = exportService.export(projectHolder.project.id, params)
checkExportNotEmpty(exported)
val responseEntity = getExportResponse(params, exported)
headersBuilder.headers(responseEntity.headers)
responseEntity.body
}
}

@PostMapping(value = [""])
@Operation(
summary = "Export data (post)",
description = """Exports data (post). Useful when exceeding allowed URL size.""",
description = """
Exports project data in various formats (JSON, properties, YAML, etc.).
Useful when exceeding allowed URL size with GET requests.

## HTTP Conditional Requests Support

This endpoint supports HTTP conditional requests using the If-Modified-Since header:

- **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date
- **Data not modified**: Returns HTTP 412 Precondition Failed with empty body (as per HTTP specification for POST requests)
- **Data modified or no header**: Returns HTTP 200 OK with the exported data and Last-Modified header

Note: Unlike GET requests which return 304 Not Modified, POST requests return 412 Precondition Failed
when the If-Modified-Since condition is not met, as POST is considered a modifying method according
to HTTP specifications.

The Last-Modified header in the response contains the timestamp of the last project modification,
which can be used for subsequent conditional requests to avoid unnecessary data transfer when the
project hasn't changed.

Cache-Control header is set to max-age=0 to ensure validation on each request.
""",
)
@RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW])
@AllowApiAccess
@ExportApiResponse
fun exportPost(
@RequestBody params: ExportParams,
): ResponseEntity<StreamingResponseBody> {
return exportData(params)
request: WebRequest
): ResponseEntity<StreamingResponseBody>? {
return exportData(params, request)
}

private fun getZipHeaders(projectName: String): HttpHeaders {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.tolgee.activity.ActivityService
import io.tolgee.activity.RequestActivity
import io.tolgee.activity.data.ActivityType
import io.tolgee.api.v2.controllers.IController
import io.tolgee.component.ProjectLastModifiedManager
import io.tolgee.component.ProjectTranslationLastModifiedManager
import io.tolgee.constants.Message
import io.tolgee.dtos.queryResults.TranslationHistoryView
Expand Down Expand Up @@ -57,7 +58,6 @@ import org.springframework.data.domain.Sort
import org.springframework.data.web.PagedResourcesAssembler
import org.springframework.data.web.SortDefault
import org.springframework.hateoas.PagedModel
import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.WebDataBinder
Expand All @@ -73,7 +73,6 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.WebRequest
import java.util.concurrent.TimeUnit

@Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection")
@RestController
Expand Down Expand Up @@ -106,6 +105,7 @@ class TranslationsController(
private val createOrUpdateTranslationsFacade: CreateOrUpdateTranslationsFacade,
private val taskService: ITaskService,
private val translationSuggestionService: TranslationSuggestionService,
private val projectLastModifiedManager: ProjectLastModifiedManager,
) : IController {
@GetMapping(value = ["/{languages}"])
@Operation(
Expand Down Expand Up @@ -162,31 +162,19 @@ When null, resulting file will be a flat key-value object.
filterTag: List<String>? = null,
request: WebRequest,
): ResponseEntity<Map<String, Any>>? {
val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id)
return projectLastModifiedManager.onlyWhenProjectDataChanged(request) {
val permittedTags =
securityService
.filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages)

if (request.checkNotModified(lastModified)) {
return null
}

val permittedTags =
securityService
.filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages)

val response =
translationService.getTranslations(
languageTags = permittedTags,
namespace = ns,
projectId = projectHolder.project.id,
structureDelimiter = request.getStructureDelimiter(),
filterTag = filterTag,
)

return ResponseEntity.ok()
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS))
.body(
response,
)
}
}

@PutMapping("")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.tolgee.component

import io.tolgee.security.ProjectHolder
import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Component
import org.springframework.web.context.request.WebRequest
import java.util.concurrent.TimeUnit

/**
* Component responsible for managing HTTP conditional requests based on project data modifications.
*
* This manager implements the HTTP conditional request mechanism (If-Modified-Since/Last-Modified headers)
* to enable efficient caching of project-related data. It helps reduce unnecessary data transfer and
* processing by allowing clients to cache responses and only receive new data when the project has
* actually been modified.
*/
@Component
class ProjectLastModifiedManager(
private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager,
private val projectHolder: ProjectHolder
) {
/**
* Executes a function only when the project data has been modified since the client's last request.
*
* This method implements HTTP conditional request handling by:
* 1. Retrieving the last modification timestamp of the current project
* 2. Checking if the client's If-Modified-Since header indicates the data is still current
* 3. If data hasn't changed, returning null (which translates to HTTP 304 Not Modified)
* 4. If data has changed, executing the provided function and wrapping the result in a ResponseEntity
* with appropriate cache control headers
*
* The response includes:
* - Last-Modified header set to the project's modification timestamp
* - Cache-Control header set to max-age=0 to ensure validation on each request
*
* This mechanism helps optimize performance by preventing export data computation and loading from database when
* not modified.
*
*/
fun <T> onlyWhenProjectDataChanged(
request: WebRequest,
fn: (
/**
* Enables setting of additional headers on the response.
*/
headersBuilder: ResponseEntity.HeadersBuilder<*>
) -> T?
): ResponseEntity<T>? {
val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id)

if (request.checkNotModified(lastModified)) {
return null
}

val headersBuilder = ResponseEntity
.ok()
.lastModified(lastModified)
.cacheControl(DEFAULT_CACHE_CONTROL_HEADER)

val response = fn(headersBuilder)

return headersBuilder
.body(
response,
)
}

companion object {
val DEFAULT_CACHE_CONTROL_HEADER = CacheControl.maxAge(0, TimeUnit.SECONDS)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.api.v2.controllers.IController
import io.tolgee.component.ProjectLastModifiedManager
import io.tolgee.model.enums.Scope
import io.tolgee.security.ProjectHolder
import io.tolgee.security.authentication.AllowApiAccess
Expand All @@ -15,6 +16,7 @@ import io.tolgee.util.StreamingResponseBodyProvider
import org.apache.tomcat.util.http.fileupload.IOUtils
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.io.ByteArrayInputStream
import java.io.OutputStream
Expand All @@ -36,6 +38,7 @@ class ExportController(
private val projectHolder: ProjectHolder,
private val authenticationFacade: AuthenticationFacade,
private val streamingResponseBodyProvider: StreamingResponseBodyProvider,
private val projectLastModifiedManager: ProjectLastModifiedManager,
) : IController {
@GetMapping(value = ["/jsonZip"], produces = ["application/zip"])
@Operation(summary = "Export to ZIP of jsons", description = "Exports data as ZIP of jsons", deprecated = true)
Expand All @@ -44,39 +47,39 @@ class ExportController(
@Deprecated("Use v2 export controller")
fun doExportJsonZip(
@PathVariable("projectId") projectId: Long?,
): ResponseEntity<StreamingResponseBody> {
val allLanguages =
permissionService.getPermittedViewLanguages(
projectHolder.project.id,
authenticationFacade.authenticatedUser.id,
)
request: WebRequest
): ResponseEntity<StreamingResponseBody>? {
return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder ->
val allLanguages =
permissionService.getPermittedViewLanguages(
projectHolder.project.id,
authenticationFacade.authenticatedUser.id,
)

return ResponseEntity
.ok()
.header(
headersBuilder.header(
"Content-Disposition",
String.format("attachment; filename=\"%s.zip\"", projectHolder.project.name),
)
.body(
streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
val zipOutputStream = ZipOutputStream(out)
val translations =
translationService.getTranslations(
allLanguages.map { it.tag }.toSet(),
null,
projectHolder.project.id,
'.',
)
for ((key, value) in translations) {
zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
val byteArrayInputStream = ByteArrayInputStream(data)
IOUtils.copy(byteArrayInputStream, zipOutputStream)
byteArrayInputStream.close()
zipOutputStream.closeEntry()
}
zipOutputStream.close()
},
)

streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
val zipOutputStream = ZipOutputStream(out)
val translations =
translationService.getTranslations(
allLanguages.map { it.tag }.toSet(),
null,
projectHolder.project.id,
'.',
)
for ((key, value) in translations) {
zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
val byteArrayInputStream = ByteArrayInputStream(data)
IOUtils.copy(byteArrayInputStream, zipOutputStream)
byteArrayInputStream.close()
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
}
}
}
Loading