Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature #8103 - Implemented functionality for retrieving database metadata and data with export options. #8174

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
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: 0 additions & 1 deletion core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
<springdoc.version>2.3.0</springdoc.version>
<thymeleaf.spring6.version>3.1.2.RELEASE</thymeleaf.spring6.version>
<thymeleaf.extras.version>3.0.4.RELEASE</thymeleaf.extras.version>
<apache.poi.version>5.2.5</apache.poi.version>
<kotlin.stdlib.version>1.9.20</kotlin.stdlib.version>
<azure.webapp.maven.plugin.version>2.12.0</azure.webapp.maven.plugin.version>
<google.cloud.storage.version>2.29.1</google.cloud.storage.version>
Expand Down
4 changes: 3 additions & 1 deletion core/src/main/java/greencity/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public class SecurityConfig {
private static final String INVITATION_ID = "/{invitationId}";
private static final String COMMIT_INFO = "/commit-info";
public static final String LOGS = "/logs/**";
public static final String SETTINGS_EXPORT = "/settings/export/**";
private final JwtTool jwtTool;
private final UserService userService;
private final AuthenticationConfiguration authenticationConfiguration;
Expand Down Expand Up @@ -292,7 +293,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
FRIENDS,
NOTIFICATIONS,
HABIT_ASSIGN_ID + "/friends/habit-duration-info",
"/ai/**")
"/ai/**",
SETTINGS_EXPORT)
.hasAnyRole(USER, ADMIN, MODERATOR, UBS_EMPLOYEE)
.requestMatchers(HttpMethod.POST,
CATEGORIES,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package greencity.controller;

import greencity.constant.HttpStatuses;
import greencity.dto.exportsettings.TableRowsDto;
import greencity.dto.exportsettings.TablesMetadataDto;
import greencity.service.ExportSettingsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.bind.annotation.RequestHeader;

@RequiredArgsConstructor
@RestController
@RequestMapping("/export/settings")
public class ExportSettingsController {
private final ExportSettingsService exportSettingsService;

/**
* Method for receiving all DB tables names.
*
* @return dto {@link TablesMetadataDto}
*/
@Operation(summary = "Get all tables names and columns.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = HttpStatuses.OK,
content = @Content(schema = @Schema(implementation = TablesMetadataDto.class))),
@ApiResponse(responseCode = "403", description = HttpStatuses.FORBIDDEN,
content = @Content(examples = @ExampleObject(HttpStatuses.FORBIDDEN))),
})
@GetMapping(value = "/tables", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TablesMetadataDto> getTablesInfo(@RequestHeader String secretKey) {
return ResponseEntity.ok(exportSettingsService.getTablesMetadata(secretKey));
}

/**
* Method for receiving rows from DB by table name, limit and offset.
*
* @return dto {@link TableRowsDto}
*/
@Operation(summary = "Get table rows by params.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = HttpStatuses.OK,
content = @Content(schema = @Schema(implementation = TableRowsDto.class))),
@ApiResponse(responseCode = "403", description = HttpStatuses.FORBIDDEN,
content = @Content(examples = @ExampleObject(HttpStatuses.FORBIDDEN))),
@ApiResponse(responseCode = "400", description = HttpStatuses.BAD_REQUEST,
content = @Content(examples = @ExampleObject(HttpStatuses.BAD_REQUEST)))
})
@GetMapping("/select")
public ResponseEntity<TableRowsDto> getSelected(@RequestParam @Pattern(regexp = "^[A-Za-z_]+$") String tableName,
@RequestParam int limit,
@RequestParam int offset,
@RequestHeader String secretKey) {
return ResponseEntity.ok(exportSettingsService.selectFromTable(tableName, limit, offset, secretKey));
}
Comment on lines +48 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Input validation for table selection needs enhancement.

While the regex validation on tableName is a good security practice, the limit and offset parameters should also be validated to prevent potential issues:

  1. Negative values could cause unexpected behavior
  2. Excessively large limit values might impact performance or memory usage

Consider adding validation annotations such as:

-    public ResponseEntity<TableRowsDto> getSelected(@RequestParam @Pattern(regexp = "^[A-Za-z_]+$") String tableName,
-        @RequestParam int limit,
-        @RequestParam int offset,
+    public ResponseEntity<TableRowsDto> getSelected(
+        @RequestParam @Pattern(regexp = "^[A-Za-z_]+$") String tableName,
+        @RequestParam @Min(value = 1, message = "Limit must be positive") 
+        @Max(value = 1000, message = "Limit cannot exceed 1000") int limit,
+        @RequestParam @Min(value = 0, message = "Offset cannot be negative") int offset,
         @RequestHeader String secretKey) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Method for receiving rows from DB by table name, limit and offset.
*
* @return dto {@link TableRowsDto}
*/
@Operation(summary = "Get table rows by params.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = HttpStatuses.OK,
content = @Content(schema = @Schema(implementation = TableRowsDto.class))),
@ApiResponse(responseCode = "403", description = HttpStatuses.FORBIDDEN,
content = @Content(examples = @ExampleObject(HttpStatuses.FORBIDDEN))),
@ApiResponse(responseCode = "400", description = HttpStatuses.BAD_REQUEST,
content = @Content(examples = @ExampleObject(HttpStatuses.BAD_REQUEST)))
})
@GetMapping("/select")
public ResponseEntity<TableRowsDto> getSelected(@RequestParam @Pattern(regexp = "^[A-Za-z_]+$") String tableName,
@RequestParam int limit,
@RequestParam int offset,
@RequestHeader String secretKey) {
return ResponseEntity.ok(exportSettingsService.selectFromTable(tableName, limit, offset, secretKey));
}
/**
* Method for receiving rows from DB by table name, limit and offset.
*
* @return dto {@link TableRowsDto}
*/
@Operation(summary = "Get table rows by params.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = HttpStatuses.OK,
content = @Content(schema = @Schema(implementation = TableRowsDto.class))),
@ApiResponse(responseCode = "403", description = HttpStatuses.FORBIDDEN,
content = @Content(examples = @ExampleObject(HttpStatuses.FORBIDDEN))),
@ApiResponse(responseCode = "400", description = HttpStatuses.BAD_REQUEST,
content = @Content(examples = @ExampleObject(HttpStatuses.BAD_REQUEST)))
})
@GetMapping("/select")
public ResponseEntity<TableRowsDto> getSelected(
@RequestParam @Pattern(regexp = "^[A-Za-z_]+$") String tableName,
@RequestParam @Min(value = 1, message = "Limit must be positive")
@Max(value = 1000, message = "Limit cannot exceed 1000") int limit,
@RequestParam @Min(value = 0, message = "Offset cannot be negative") int offset,
@RequestHeader String secretKey) {
return ResponseEntity.ok(exportSettingsService.selectFromTable(tableName, limit, offset, secretKey));
}


/**
* Method for receiving an .xlsx file with rows from DB by table name, limit and
* offset.
*
* @return dto {@link TableRowsDto}
*/
@Operation(summary = "Get excel file with table rows by params.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = HttpStatuses.OK,
content = @Content(schema = @Schema(implementation = TableRowsDto.class))),
@ApiResponse(responseCode = "403", description = HttpStatuses.FORBIDDEN,
content = @Content(examples = @ExampleObject(HttpStatuses.FORBIDDEN))),
})
@GetMapping("/download-table-data")
public ResponseEntity<InputStreamResource> downloadExcel(
@RequestParam @Pattern(regexp = "^[A-Za-z_]+$") String tableName,
@RequestParam int limit,
@RequestParam int offset,
@RequestHeader String secretKey) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION,
String.format("attachment; filename= %s(%d - %d).xlsx", tableName, offset, limit));
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE);

return ResponseEntity.ok()
.headers(headers)
.body(new InputStreamResource(
exportSettingsService.getExcelFileAsResource(tableName, limit, offset, secretKey)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
import greencity.exception.exceptions.BadRequestException;
import greencity.exception.exceptions.BadSecretKeyException;
import greencity.exception.exceptions.BadSocialNetworkLinksException;
import greencity.exception.exceptions.DatabaseMetadataException;
import greencity.exception.exceptions.EventDtoValidationException;
import greencity.exception.exceptions.FileGenerationException;
import greencity.exception.exceptions.FileReadException;
import greencity.exception.exceptions.FunctionalityNotAvailableException;
import greencity.exception.exceptions.InvalidLimitException;
import greencity.exception.exceptions.InvalidStatusException;
import greencity.exception.exceptions.InvalidURLException;
import greencity.exception.exceptions.LowRoleLevelException;
Expand Down Expand Up @@ -623,4 +626,56 @@ public final ResponseEntity<Object> handleFunctionalityNotAvailableException(Fun

return ResponseEntity.status(HttpStatus.FORBIDDEN).body(exceptionResponse);
}

/**
* Method intercepts exception {@link DatabaseMetadataException}.
*
* @param ex Exception that should be intercepted.
* @param request Contains details about the occurred exception.
* @return {@code ResponseEntity} which contains the HTTP status and body with
* the exception message.
*/
@ExceptionHandler(DatabaseMetadataException.class)
public final ResponseEntity<Object> handleDatabaseMetadataException(DatabaseMetadataException ex,
WebRequest request) {
log.error(ex.getMessage(), ex);
ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request));
exceptionResponse.setMessage(ex.getMessage());

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(exceptionResponse);
}

/**
* Method intercepts exception {@link InvalidLimitException}.
*
* @param ex Exception that should be intercepted.
* @param request Contains details about the occurred exception.
* @return {@code ResponseEntity} which contains the HTTP status and body with
* the exception message.
*/
@ExceptionHandler(InvalidLimitException.class)
public final ResponseEntity<Object> handleInvalidOffsetException(InvalidLimitException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request));
exceptionResponse.setMessage(ex.getMessage());

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse);
}
Comment on lines +656 to +663
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Method name doesn't match the exception being handled.

While the implementation is correct, the method name handleInvalidOffsetException doesn't match the exception type InvalidLimitException that it's handling. This could lead to confusion during maintenance.

Apply this change:

-public final ResponseEntity<Object> handleInvalidOffsetException(InvalidLimitException ex, WebRequest request) {
+public final ResponseEntity<Object> handleInvalidLimitException(InvalidLimitException ex, WebRequest request) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@ExceptionHandler(InvalidLimitException.class)
public final ResponseEntity<Object> handleInvalidOffsetException(InvalidLimitException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request));
exceptionResponse.setMessage(ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse);
}
@ExceptionHandler(InvalidLimitException.class)
public final ResponseEntity<Object> handleInvalidLimitException(InvalidLimitException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request));
exceptionResponse.setMessage(ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse);
}


/**
* Method intercepts exception {@link FileGenerationException}.
*
* @param ex Exception that should be intercepted.
* @param request Contains details about the occurred exception.
* @return {@code ResponseEntity} which contains the HTTP status and body with
* the exception message.
*/
@ExceptionHandler(FileGenerationException.class)
public final ResponseEntity<Object> handleFileGenerationException(FileGenerationException ex, WebRequest request) {
log.error(ex.getMessage(), ex);
ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request));
exceptionResponse.setMessage(ex.getMessage());

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(exceptionResponse);
}
}
29 changes: 29 additions & 0 deletions core/src/test/java/greencity/ModelUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import greencity.dto.location.MapBoundsDto;
import greencity.dto.logs.filter.LogFileFilterDto;
import greencity.dto.place.PlaceByBoundsDto;
import greencity.dto.exportsettings.TableRowsDto;
import greencity.dto.exportsettings.TablesMetadataDto;
import greencity.dto.todolistitem.CustomToDoListItemResponseDto;
import greencity.dto.todolistitem.ToDoListItemPostDto;
import greencity.dto.todolistitem.ToDoListItemRequestDto;
Expand Down Expand Up @@ -81,6 +83,8 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.LinkedList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
Expand Down Expand Up @@ -660,4 +664,29 @@ public static AddressDto getAddressDtoCorrect() {
.countryEn("Country")
.build();
}

public static TablesMetadataDto getTablesMetadataDto() {
Map<String, List<String>> tables = new HashMap<>();
List<String> columns = List.of("id", "name", "email");
tables.put("users", columns);

return TablesMetadataDto.builder()
.tables(tables)
.build();
}

public static TableRowsDto getTableRowsDto() {
List<Map<String, String>> tableData = new LinkedList<>();
Map<String, String> row = new LinkedHashMap<>();
row.put("id", "1");
row.put("date_of_registration", "1970-01-01 00:00:00");
row.put("email", "[email protected]");
row.put("name", "Name");
row.put("role", "ROLE_ADMIN");
tableData.add(row);

return TableRowsDto.builder()
.tableData(tableData)
.build();
}
}
Loading
Loading