Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f34aee3
Moved HelloController to controller/HelloController
f-s-h Jun 9, 2026
54045fa
Implemented placeholder MemberController and MemberService
f-s-h Jun 9, 2026
d6f6507
Implemented test for getAllMembers
f-s-h Jun 9, 2026
159e569
Deleted redundant MemberDTO
f-s-h Jun 9, 2026
3b1a3f7
Implemented getAllMembers() in MemberController
f-s-h Jun 9, 2026
e4ac893
Added comments for test cases
f-s-h Jun 9, 2026
1fe590b
Added comment describing getAllMembers
f-s-h Jun 9, 2026
8e11443
Placeholder for getMemberById
f-s-h Jun 9, 2026
2e2f4ca
Added tests for getMemberById
f-s-h Jun 9, 2026
a09d55d
Removed UUID type test, as SpringBoot automatically returns 400 if th…
f-s-h Jun 9, 2026
2f786c1
Added @BeforeEach setup that creates a Member used for testing
f-s-h Jun 9, 2026
9298977
Implemented getMemberById endpoint
f-s-h Jun 9, 2026
33ec9c0
Implementd createUser in KeycloakService that creates a user in Keycl…
f-s-h Jun 9, 2026
c25a0cc
Implement createMember
f-s-h Jun 9, 2026
55f6037
Implemented updateMember
f-s-h Jun 9, 2026
d2d5313
Implemented getMemberDetails
f-s-h Jun 10, 2026
be3c85b
Implemented deleteMember
f-s-h Jun 10, 2026
19705ca
Merge remote-tracking branch 'origin/main' into feature/29-member-ser…
f-s-h Jun 12, 2026
2a85599
Merge branch 'main' into feature/29-member-service-crud
f-s-h Jun 14, 2026
4f07cb1
Renamed from MemberServiceApplicationTests.java to MemberServiceAppli…
f-s-h Jun 17, 2026
a5aed03
Renamed getMemberById to getMemberSummaryById and getMemberDetailsByI…
f-s-h Jun 17, 2026
1c4740c
Implemented MemberConverter
f-s-h Jun 17, 2026
8f62244
Moved Tests to subfolder
f-s-h Jun 17, 2026
e8320f8
Removed unnecessary Test
f-s-h Jun 17, 2026
4c6421c
Added AllArgsConstructor
f-s-h Jun 17, 2026
895e9f8
Implemented KeycloakService
f-s-h Jun 17, 2026
66e1a94
Implemented equals
f-s-h Jun 19, 2026
bdc395f
Implemented MemberService
f-s-h Jun 19, 2026
0336bcc
Fixed linting errors
f-s-h Jun 19, 2026
ffc8c78
Fixed linting errors
f-s-h Jun 19, 2026
310be7e
Merge branch 'main' into feature/29-member-service-crud
raphael-frank Jun 25, 2026
5654fab
fix: member service spec compliance and crud correctness
raphael-frank Jun 25, 2026
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
@@ -1,4 +1,4 @@
package tum.devoops.memberservice;
package tum.devoops.memberservice.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package tum.devoops.memberservice.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RestController;
import tum.devoops.memberservice.model.Member;
import tum.devoops.memberservice.model.MemberCreate;
import tum.devoops.memberservice.model.MemberPartialUpdate;
import tum.devoops.memberservice.model.MemberSummary;
import tum.devoops.memberservice.service.MemberService;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@RestController
public class MemberController {

private final MemberService memberService;

public MemberController(MemberService memberService) {
this.memberService = memberService;
}

@PreAuthorize("hasAnyRole('member', 'admin')")
@GetMapping("/")
public ResponseEntity<List<MemberSummary>> getAllMembers() {
return ResponseEntity.ok(memberService.getAllMembers());
}

@PreAuthorize("hasAnyRole('member', 'admin')")
@GetMapping("/{id}")
public ResponseEntity<Member> getMemberDetails(@PathVariable UUID id) {
Optional<Member> memberOptional = memberService.getMemberById(id);
return memberOptional.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}

@PreAuthorize("hasRole('admin')")
@PostMapping("/")
public ResponseEntity<Member> createMember(@Valid @RequestBody MemberCreate memberCreate, @AuthenticationPrincipal Jwt jwt) {
try {
Optional<Member> optionalMember = memberService.createMember(memberCreate, jwt.getTokenValue());
if (optionalMember.isEmpty()) {
return ResponseEntity.badRequest().build();
}
Member member = optionalMember.get();
return ResponseEntity.created(URI.create("/" + member.getId())).body(member);
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}

@PreAuthorize("hasRole('admin') or hasRole('member') and #id.toString() == authentication.name")
@PatchMapping("/{id}")
public ResponseEntity<Member> updateMemberDetails(
@PathVariable UUID id,
@Valid @RequestBody MemberPartialUpdate memberPartialUpdate,
@AuthenticationPrincipal Jwt jwt) {
try {
Optional<Member> updated = memberService.updateMember(id, memberPartialUpdate, jwt.getTokenValue());
return updated.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}

@PreAuthorize("hasRole('admin')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMember(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) {
boolean deleted = memberService.deleteMember(id, jwt.getTokenValue());
return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package tum.devoops.memberservice.converter;

import tum.devoops.memberservice.entity.MemberEntity;
import tum.devoops.memberservice.model.Member;
import tum.devoops.memberservice.model.MemberCreate;
import tum.devoops.memberservice.model.MemberPartialUpdate;
import tum.devoops.memberservice.model.MemberSummary;

import java.time.LocalDate;
import java.util.UUID;

public class MemberConverter {
public static Member convertMemberEntityToMember(MemberEntity memberEntity) {
return new Member(
memberEntity.getId(),
memberEntity.getFirstName(),
memberEntity.getLastName(),
memberEntity.getEmail(),
memberEntity.getBirthday(),
memberEntity.getPhoneNumber(),
memberEntity.getAddress(),
memberEntity.getJoiningDate(),
memberEntity.getInformation()
);
}

public static MemberEntity convertMemberCreateToMemberEntity(MemberCreate memberCreate, UUID id) {
return new MemberEntity(
id,
memberCreate.getFirstName(),
memberCreate.getLastName(),
memberCreate.getEmail(),
memberCreate.getBirthday(),
memberCreate.getPhoneNumber(),
memberCreate.getAddress(),
LocalDate.now(),
memberCreate.getInformation()
);
}

public static MemberSummary convertMemberEntityToMemberSummary(MemberEntity memberEntity) {
return new MemberSummary(memberEntity.getId(), memberEntity.getFirstName(), memberEntity.getLastName(), memberEntity.getEmail());
}

public static void applyPartialUpdate(MemberEntity entity, MemberPartialUpdate update) {
if (update.getFirstName() != null) {
entity.setFirstName(update.getFirstName());
}
if (update.getLastName() != null) {
entity.setLastName(update.getLastName());
}
if (update.getEmail() != null) {
entity.setEmail(update.getEmail());
}
if (update.getBirthday() != null) {
entity.setBirthday(update.getBirthday());
}
if (update.getPhoneNumber() != null) {
entity.setPhoneNumber(update.getPhoneNumber());
}
if (update.getAddress() != null) {
entity.setAddress(update.getAddress());
}
if (update.getInformation() != null) {
entity.setInformation(update.getInformation());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
import java.time.LocalDate;
import java.util.UUID;

import java.util.Objects;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(schema = "member", name = "members")
@Getter @Setter @NoArgsConstructor
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class MemberEntity {

@Id
Expand Down Expand Up @@ -46,4 +49,25 @@ public class MemberEntity {

@Column(name = "information", nullable = true, columnDefinition = "TEXT")
private String information;

@Override
public boolean equals(Object o) {
if (!(o instanceof MemberEntity other)) {
return false;
}
return Objects.equals(id, other.id)
&& Objects.equals(firstName, other.firstName)
&& Objects.equals(lastName, other.lastName)
&& Objects.equals(email, other.email)
&& Objects.equals(birthday, other.birthday)
&& Objects.equals(phoneNumber, other.phoneNumber)
&& Objects.equals(address, other.address)
&& Objects.equals(joiningDate, other.joiningDate)
&& Objects.equals(information, other.information);
}

@Override
public int hashCode() {
return Objects.hash(id, firstName, lastName, email, birthday, phoneNumber, address, joiningDate, information);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package tum.devoops.memberservice.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import tum.devoops.memberservice.model.Member;
import tum.devoops.memberservice.model.MemberCreate;

import java.net.URI;
import java.util.List;
import java.util.UUID;

@Service
public class KeycloakService {

private final RestClient restClient;
@Value("${keycloak.realm}")
private String realm;

public KeycloakService(RestClient.Builder restClientBuilder, @Value("${keycloak.base-url}") String baseUrl) {
this.restClient = restClientBuilder.baseUrl(baseUrl).build();
}

public UUID createUser(MemberCreate member, String bearerToken) throws Exception {
String username = member.getEmail() != null ? member.getEmail() : (member.getFirstName() + member.getLastName()).toLowerCase();

List<Credential> credentials = member.getPassword() != null
? List.of(new Credential("password", member.getPassword(), false))
: List.of();

UserRepresentation body = new UserRepresentation(
username, member.getFirstName(), member.getLastName(), member.getEmail(), true, credentials);

ResponseEntity<Void> response;

try {
response = restClient.post()
.uri("/admin/realms/{realm}/users", realm)
.header("Authorization", "Bearer " + bearerToken)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (HttpClientErrorException.Conflict e) {
throw new IllegalAccessException("A keycloak user with this username/email already exists");
} catch (HttpClientErrorException.Forbidden e) {
throw new SecurityException("Insufficient permissions to create a keycloak user");
}

URI location = response.getHeaders().getLocation();

if (location == null) {
throw new IllegalStateException("Keycloak did not return a location header after user creation");
}

String path = location.getPath();
return UUID.fromString(path.substring(path.lastIndexOf("/") + 1));
}

public void updateUser(Member member, String bearerToken) throws HttpClientErrorException {

UserRepresentation body = new UserRepresentation(member.getEmail(), member.getFirstName(),
member.getLastName(), member.getEmail(), true, List.of());

try {
restClient.put()
.uri("/admin/realms/{realm}/users/{id}", realm, member.getId())
.header("Authorization", "Bearer " + bearerToken)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (HttpClientErrorException.Conflict e) {
throw new IllegalArgumentException("A Keycloak user with this email already exists");
} catch (HttpClientErrorException.Forbidden e) {
throw new SecurityException("Insufficient permissions to update this Keycloak user");
}
}

public void deleteUser(UUID keycloakId, String bearerToken) throws HttpClientErrorException, SecurityException {
try {
restClient.delete()
.uri("/admin/realms/{realm}/users/{id}", realm, keycloakId)
.header("Authorization", "Bearer " + bearerToken)
.retrieve()
.toBodilessEntity();
} catch (HttpClientErrorException.NotFound e) {
throw new IllegalArgumentException("Keycloak user not found: " + keycloakId);
} catch (HttpClientErrorException.Forbidden e) {
throw new SecurityException("Insufficient permissions to delete a keycloak user");
}
}

private record UserRepresentation(
String username,
String firstName,
String lastName,
String email,
boolean enabled,
List<Credential> credentials
) {}

private record Credential(String type, String value, boolean temporary) {}
}
Loading
Loading