Skip to content

Commit 8deddc1

Browse files
feat: add Docker, OpenAPI docs, logging and Prometheus observability
Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/5f1775da-aadb-4d0a-8e9b-d95b9333e860 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
1 parent 03e8e6a commit 8deddc1

34 files changed

Lines changed: 750 additions & 23 deletions

Dockerfile

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# ============================================================
2+
# Stage 1: Build
3+
# ============================================================
4+
FROM eclipse-temurin:21-jdk-alpine AS build
5+
6+
# Install Maven
7+
RUN apk add --no-cache maven
8+
9+
WORKDIR /workspace
10+
11+
# Copy POM first to cache dependency layer
12+
COPY backend/pom.xml .
13+
14+
# Download dependencies (cached unless pom.xml changes)
15+
RUN mvn dependency:go-offline -B --no-transfer-progress
16+
17+
# Copy source code and build the fat JAR, skipping tests
18+
COPY backend/src src
19+
RUN mvn package -DskipTests -B --no-transfer-progress
20+
21+
# ============================================================
22+
# Stage 2: Runtime
23+
# ============================================================
24+
FROM eclipse-temurin:21-jre-alpine AS runtime
25+
26+
# Create a non-root user for security
27+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
28+
29+
WORKDIR /app
30+
31+
# Copy the built JAR from the build stage
32+
COPY --from=build /workspace/target/*.jar app.jar
33+
34+
# Ensure the app directory is owned by the non-root user
35+
RUN chown -R appuser:appgroup /app
36+
37+
USER appuser
38+
39+
# Expose application port
40+
EXPOSE 8080
41+
42+
# Health check – relies on Spring Actuator
43+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
44+
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
45+
46+
# JVM tuning flags for containers (respects cgroup memory limits)
47+
ENV JAVA_OPTS="-XX:+UseContainerSupport \
48+
-XX:MaxRAMPercentage=75.0 \
49+
-XX:+ExitOnOutOfMemoryError \
50+
-Djava.security.egd=file:/dev/./urandom"
51+
52+
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

backend/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@
8484
<version>2.5.0</version>
8585
</dependency>
8686

87+
<!-- Actuator -->
88+
<dependency>
89+
<groupId>org.springframework.boot</groupId>
90+
<artifactId>spring-boot-starter-actuator</artifactId>
91+
</dependency>
92+
93+
<!-- Micrometer Prometheus Registry -->
94+
<dependency>
95+
<groupId>io.micrometer</groupId>
96+
<artifactId>micrometer-registry-prometheus</artifactId>
97+
</dependency>
98+
8799
<!-- Test -->
88100
<dependency>
89101
<groupId>org.springframework.boot</groupId>

backend/src/main/java/com/jobtracker/config/OpenApiConfig.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ public OpenAPI openAPI() {
1616
final String securitySchemeName = "bearerAuth";
1717
return new OpenAPI()
1818
.info(new Info()
19-
.title("Job Tracker API")
20-
.description("REST API for Job Application Tracker PWA")
19+
.title("Job Apply Tracker API")
20+
.description("API for tracking job applications")
2121
.version("1.0.0"))
2222
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
2323
.components(new Components()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.jobtracker.config;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.lang.NonNull;
10+
import org.springframework.security.core.Authentication;
11+
import org.springframework.security.core.context.SecurityContextHolder;
12+
import org.springframework.stereotype.Component;
13+
import org.springframework.web.filter.OncePerRequestFilter;
14+
15+
import java.io.IOException;
16+
17+
@Component
18+
public class RequestLoggingFilter extends OncePerRequestFilter {
19+
20+
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
21+
22+
@Override
23+
protected void doFilterInternal(@NonNull HttpServletRequest request,
24+
@NonNull HttpServletResponse response,
25+
@NonNull FilterChain filterChain) throws ServletException, IOException {
26+
long start = System.currentTimeMillis();
27+
try {
28+
filterChain.doFilter(request, response);
29+
} finally {
30+
long duration = System.currentTimeMillis() - start;
31+
String userId = resolveUserId();
32+
log.info("method={} path={} status={} duration={}ms userId={}",
33+
request.getMethod(),
34+
request.getRequestURI(),
35+
response.getStatus(),
36+
duration,
37+
userId);
38+
}
39+
}
40+
41+
private String resolveUserId() {
42+
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
43+
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
44+
return auth.getName();
45+
}
46+
return "anonymous";
47+
}
48+
}

backend/src/main/java/com/jobtracker/config/SecurityConfig.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@ public class SecurityConfig {
1818

1919
private final JwtAuthenticationFilter jwtAuthenticationFilter;
2020
private final CorsConfig corsConfig;
21+
private final RequestLoggingFilter requestLoggingFilter;
2122

22-
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig) {
23+
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig,
24+
RequestLoggingFilter requestLoggingFilter) {
2325
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
2426
this.corsConfig = corsConfig;
27+
this.requestLoggingFilter = requestLoggingFilter;
2528
}
2629

2730
@Bean
@@ -36,9 +39,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
3639
"/api/auth/refresh", "/api/auth/forgot-password",
3740
"/api/auth/reset-password").permitAll()
3841
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
42+
.requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus").permitAll()
3943
.anyRequest().authenticated()
4044
)
41-
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
45+
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
46+
.addFilterBefore(requestLoggingFilter, JwtAuthenticationFilter.class);
4247

4348
return http.build();
4449
}

backend/src/main/java/com/jobtracker/controller/ApplicationController.java

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
import com.jobtracker.dto.application.*;
44
import com.jobtracker.service.ApplicationService;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.Parameter;
7+
import io.swagger.v3.oas.annotations.media.Content;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
511
import jakarta.validation.Valid;
612
import org.springframework.format.annotation.DateTimeFormat;
713
import org.springframework.http.HttpStatus;
@@ -12,6 +18,7 @@
1218
import java.util.List;
1319
import java.util.Map;
1420

21+
@Tag(name = "Applications", description = "Job application management endpoints")
1522
@RestController
1623
@RequestMapping("/api/applications")
1724
public class ApplicationController {
@@ -22,60 +29,140 @@ public ApplicationController(ApplicationService applicationService) {
2229
this.applicationService = applicationService;
2330
}
2431

32+
@Operation(
33+
summary = "Create a job application",
34+
description = "Creates a new job application for the authenticated user",
35+
responses = {
36+
@ApiResponse(responseCode = "201", description = "Application created",
37+
content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
38+
@ApiResponse(responseCode = "400", description = "Validation error")
39+
}
40+
)
2541
@PostMapping
2642
public ResponseEntity<ApplicationResponse> create(@Valid @RequestBody ApplicationRequest request) {
2743
return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request));
2844
}
2945

46+
@Operation(
47+
summary = "Get application by ID",
48+
description = "Returns a single job application owned by the authenticated user",
49+
responses = {
50+
@ApiResponse(responseCode = "200", description = "Application found",
51+
content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
52+
@ApiResponse(responseCode = "404", description = "Application not found")
53+
}
54+
)
3055
@GetMapping("/{id}")
31-
public ResponseEntity<ApplicationResponse> getById(@PathVariable Long id) {
56+
public ResponseEntity<ApplicationResponse> getById(
57+
@Parameter(description = "Application ID", required = true) @PathVariable Long id) {
3258
return ResponseEntity.ok(applicationService.getById(id));
3359
}
3460

61+
@Operation(
62+
summary = "Update a job application",
63+
description = "Replaces all fields of an existing job application",
64+
responses = {
65+
@ApiResponse(responseCode = "200", description = "Application updated",
66+
content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
67+
@ApiResponse(responseCode = "400", description = "Validation error"),
68+
@ApiResponse(responseCode = "404", description = "Application not found")
69+
}
70+
)
3571
@PutMapping("/{id}")
36-
public ResponseEntity<ApplicationResponse> update(@PathVariable Long id,
37-
@Valid @RequestBody ApplicationRequest request) {
72+
public ResponseEntity<ApplicationResponse> update(
73+
@Parameter(description = "Application ID", required = true) @PathVariable Long id,
74+
@Valid @RequestBody ApplicationRequest request) {
3875
return ResponseEntity.ok(applicationService.update(id, request));
3976
}
4077

78+
@Operation(
79+
summary = "Update application status",
80+
description = "Partially updates only the status field of an application",
81+
responses = {
82+
@ApiResponse(responseCode = "200", description = "Status updated",
83+
content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
84+
@ApiResponse(responseCode = "404", description = "Application not found")
85+
}
86+
)
4187
@PatchMapping("/{id}/status")
42-
public ResponseEntity<ApplicationResponse> updateStatus(@PathVariable Long id,
43-
@Valid @RequestBody UpdateStatusRequest request) {
88+
public ResponseEntity<ApplicationResponse> updateStatus(
89+
@Parameter(description = "Application ID", required = true) @PathVariable Long id,
90+
@Valid @RequestBody UpdateStatusRequest request) {
4491
return ResponseEntity.ok(applicationService.updateStatus(id, request));
4592
}
4693

94+
@Operation(
95+
summary = "Update recruiter DM reminder",
96+
description = "Enables or disables the recruiter DM reminder for an application",
97+
responses = {
98+
@ApiResponse(responseCode = "200", description = "Reminder updated",
99+
content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
100+
@ApiResponse(responseCode = "404", description = "Application not found")
101+
}
102+
)
47103
@PatchMapping("/{id}/reminder")
48-
public ResponseEntity<ApplicationResponse> updateReminder(@PathVariable Long id,
49-
@Valid @RequestBody UpdateReminderRequest request) {
104+
public ResponseEntity<ApplicationResponse> updateReminder(
105+
@Parameter(description = "Application ID", required = true) @PathVariable Long id,
106+
@Valid @RequestBody UpdateReminderRequest request) {
50107
return ResponseEntity.ok(applicationService.updateReminder(id, request));
51108
}
52109

110+
@Operation(
111+
summary = "Delete a job application",
112+
responses = {
113+
@ApiResponse(responseCode = "200", description = "Application deleted"),
114+
@ApiResponse(responseCode = "404", description = "Application not found")
115+
}
116+
)
53117
@DeleteMapping("/{id}")
54-
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id) {
118+
public ResponseEntity<Map<String, String>> delete(
119+
@Parameter(description = "Application ID", required = true) @PathVariable Long id) {
55120
applicationService.delete(id);
56121
return ResponseEntity.ok(Map.of("message", "Application deleted successfully"));
57122
}
58123

124+
@Operation(
125+
summary = "List job applications",
126+
description = "Returns a paginated, filterable list of job applications for the authenticated user",
127+
responses = {
128+
@ApiResponse(responseCode = "200", description = "Page of applications",
129+
content = @Content(schema = @Schema(implementation = ApplicationPageResponse.class)))
130+
}
131+
)
59132
@GetMapping
60133
public ResponseEntity<ApplicationPageResponse> getAll(
61-
@RequestParam(required = false) String status,
62-
@RequestParam(required = false) String recruiterName,
63-
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateFrom,
64-
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateTo,
65-
@RequestParam(required = false) Boolean interviewScheduled,
66-
@RequestParam(required = false) Boolean recruiterDmReminderEnabled,
67-
@RequestParam(defaultValue = "0") int page,
68-
@RequestParam(defaultValue = "10") int size,
69-
@RequestParam(required = false) String sort) {
134+
@Parameter(description = "Filter by status") @RequestParam(required = false) String status,
135+
@Parameter(description = "Filter by recruiter name") @RequestParam(required = false) String recruiterName,
136+
@Parameter(description = "Filter from date (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateFrom,
137+
@Parameter(description = "Filter to date (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateTo,
138+
@Parameter(description = "Filter by interview scheduled flag") @RequestParam(required = false) Boolean interviewScheduled,
139+
@Parameter(description = "Filter by recruiter DM reminder flag") @RequestParam(required = false) Boolean recruiterDmReminderEnabled,
140+
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
141+
@Parameter(description = "Page size") @RequestParam(defaultValue = "10") int size,
142+
@Parameter(description = "Sort field") @RequestParam(required = false) String sort) {
70143
return ResponseEntity.ok(applicationService.getAll(status, recruiterName, applicationDateFrom,
71144
applicationDateTo, interviewScheduled, recruiterDmReminderEnabled, page, size, sort));
72145
}
73146

147+
@Operation(
148+
summary = "Get upcoming applications",
149+
description = "Returns applications with upcoming next-step dates",
150+
responses = {
151+
@ApiResponse(responseCode = "200", description = "List of upcoming applications")
152+
}
153+
)
74154
@GetMapping("/upcoming")
75155
public ResponseEntity<List<ApplicationResponse>> getUpcoming() {
76156
return ResponseEntity.ok(applicationService.getUpcoming());
77157
}
78158

159+
@Operation(
160+
summary = "Get overdue applications",
161+
description = "Returns applications whose next-step date has already passed",
162+
responses = {
163+
@ApiResponse(responseCode = "200", description = "List of overdue applications")
164+
}
165+
)
79166
@GetMapping("/overdue")
80167
public ResponseEntity<List<ApplicationResponse>> getOverdue() {
81168
return ResponseEntity.ok(applicationService.getOverdue());

0 commit comments

Comments
 (0)