Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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,154 @@
package tum.devoops.memberservice.controller;

import org.springframework.beans.factory.annotation.Autowired;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.MemberSummary;
import tum.devoops.memberservice.service.MemberService;

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

@RestController
public class MemberController {

@Autowired
MemberService memberService;

/**
* Retrieves all members.
* <p>
* This endpoint searches the primary database and returns all members.
* Only the MemberSummary is returned.
* </p>
* @return ResponseEntity containing a List of MemberSummary and HTTP 200
*/
@PreAuthorize("hasAnyRole('member', 'admin')")
@GetMapping("/")
public ResponseEntity<List<MemberSummary>> getAllMembers() {
return ResponseEntity.ok(memberService.getAllMembers());
}

/**
* Retrieves a member given its ID.
* <p>
* This endpoint searches the primary database and returns the corresponding member to an ID.
* </p>
* @param id The unique {@link UUID} of the member.
* @return ResponseEntity containing a MemberSummary and HTTP 200. If the member is not found an empty
* ResponseEntity with HTTP 404 is returned.
*/
@PreAuthorize("hasAnyRole('member', 'admin')")
@GetMapping("/{id}")
public ResponseEntity<MemberSummary> getMemberSummaryById(@PathVariable UUID id) {
Optional<MemberSummary> memberOptional = memberService.getMemberSummaryById(id);

if (memberOptional.isPresent()) {
MemberSummary member = memberOptional.get();
return ResponseEntity.ok(member);
} else {
return ResponseEntity.notFound().build();
}
}

/**
* Retrieves a member with all details given its ID.
* <p>
* This endpoint searches the primary database and returns the corresponding member to an ID.
* </p>
* @param id The unique {@link UUID} of the member.
* @return ResponseEntity containing a Member and HTTP 200. If the member is not found an empty
* ResponseEntity with HTTP 404 is returned.
*/
@PreAuthorize("hasAnyRole('member', 'admin')")
@GetMapping("/{id}/details")
public ResponseEntity<Member> getMemberById(@PathVariable UUID id) {
Optional<Member> memberOptional = memberService.getMemberById(id);

if (memberOptional.isPresent()) {
Member member = memberOptional.get();
return ResponseEntity.ok(member);
} else {
return ResponseEntity.notFound().build();
}
}

/**
* Creates a member in the member-db and the corresponding user in keycloak
* <p>
* This endpoint creates a member and further creates a corresponding user in keycloak using the email of the member as username.
* If the email is null, the username is firstName.lastName
* </p>
* @param memberCreate the member without id.
* @return ResponseEntity containing the created member and HTTP 201. If the email or username exists, returns HTTP 400.
*/
@PreAuthorize("hasRole('admin')")
@PostMapping("/")
public ResponseEntity<Member> createMember(@RequestBody MemberCreate memberCreate, @AuthenticationPrincipal Jwt jwt) {
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);
}

/**
* Updates a member in the member db and the keycloak db (if email changes)
* <p>
* This endpoint updates a member and further updates the corresponding user in keycloak.
* </p>
* @param newMember the updated member.
* @return ResponseEntity containing the updated member and HTTP 200. If the member does not exist, return HTTP 404.
*/
@PreAuthorize("hasRole('admin') or hasRole('member') and #newMember.id.toString() == authentication.name")
@PutMapping("/{id}")
public ResponseEntity<Member> updateMember(@PathVariable UUID id, @RequestBody Member newMember, @AuthenticationPrincipal Jwt jwt) {

Optional<Member> newMemberOptional = memberService.updateMember(newMember, jwt.getTokenValue());

if (newMemberOptional.isEmpty()) {
return ResponseEntity.notFound().build();
}

newMember = newMemberOptional.get();
return ResponseEntity.ok(newMember);
}

/**
* Deletes a member in the member db and the keycloak db
* <p>
* This endpoint deletes a member and further deletes the corresponding user in keycloak.
* </p>
* @param id the id of the member to be deleted.
* @return Empty response and HTTP 204. If the member does not exist, return HTTP 404.
*/
@PreAuthorize("hasRole('admin')")
@DeleteMapping("/{id}")
public ResponseEntity<Member> deleteMember(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) {

boolean isDeleted = memberService.deleteMember(id, jwt.getTokenValue());

if (isDeleted) {
return ResponseEntity.noContent().build();
} else {
return ResponseEntity.notFound().build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.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 convertMemberToMemberEntity(Member member) {
return new MemberEntity(
member.getId(),
member.getFirstName(),
member.getLastName(),
member.getEmail(),
member.getBirthday(),
member.getPhoneNumber(),
member.getAddress(),
member.getJoiningDate(),
member.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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
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 +47,18 @@ public class MemberEntity {

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

@Override
public boolean equals(Object o) {
if (!(o instanceof MemberEntity)) {
return false;
}
MemberEntity other = (MemberEntity) o;

return id.equals(other.getId()) && firstName.equals(other.getFirstName())
&& lastName.equals(other.getLastName()) && email.equals(other.getEmail())
&& birthday.equals(other.getBirthday()) && phoneNumber.equals(other.getPhoneNumber())
&& address.equals(other.getAddress()) && joiningDate.equals(other.getJoiningDate())
&& information.equals(other.getInformation());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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.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();

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

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);

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
) {}
}
Loading
Loading