Skip to content
Merged
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
2 changes: 1 addition & 1 deletion server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ dependencies {
implementation("io.micronaut:micronaut-http-client")
implementation("io.micronaut:micronaut-management")
implementation('io.micronaut:micronaut-runtime')
implementation("io.micronaut.cache:micronaut-cache-ehcache")
implementation("io.micronaut.cache:micronaut-cache-caffeine")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.flyway:micronaut-flyway")
implementation("io.micronaut.security:micronaut-security")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.objectcomputing.checkins.services.memberprofile.memberphoto;

/**
* Interface to access Google Photo data, and allow for simpler mocking in tests
*/
interface GooglePhotoAccessor {

byte[] getPhotoData(String workEmail);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.objectcomputing.checkins.services.memberprofile.memberphoto;

import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.HttpStatusCodes;
import com.google.api.services.directory.Directory;
import com.google.api.services.directory.model.UserPhoto;
import com.objectcomputing.checkins.util.googleapiaccess.GoogleApiAccess;
import io.micronaut.context.env.Environment;
import jakarta.inject.Singleton;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Base64;
import java.util.Optional;

@Singleton
class GooglePhotoAccessorImpl implements GooglePhotoAccessor {

private static final Logger LOG = LoggerFactory.getLogger(GooglePhotoAccessorImpl.class);

private final byte[] defaultPhoto;
private final GoogleApiAccess googleApiAccess;

GooglePhotoAccessorImpl(
Environment environment,
GoogleApiAccess googleApiAccess
) {
this.googleApiAccess = googleApiAccess;
byte[] localDefaultPhoto = new byte[0];
Optional<URL> resource = environment.getResource("public/default_profile.jpg");
try {
if(resource.isPresent()) {
URL defaultImageUrl = resource.get();
InputStream in = defaultImageUrl.openStream();
localDefaultPhoto = IOUtils.toByteArray(in);
}
} catch (IOException e) {
LOG.error("Error occurred while loading the default profile photo.", e);
}
this.defaultPhoto = localDefaultPhoto;
}

@Override
public byte[] getPhotoData(String workEmail) {
Directory directory = googleApiAccess.getDirectory();
try {
UserPhoto userPhoto = directory.users().photos().get(workEmail).execute();
if (LOG.isDebugEnabled()) {
LOG.debug(String.format("Photo data successfully retrieved from Google Directory API for: %s", workEmail));
}
return Base64.getUrlDecoder().decode(userPhoto.getPhotoData());
} catch (GoogleJsonResponseException gjse) {
if (gjse.getStatusCode() == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
LOG.info(String.format("No photo was found for: %s", workEmail));
} else {
LOG.error(String.format("An unexpected error occurred while retrieving photo from Google Directory API for: %s", workEmail), gjse);
}
return defaultPhoto;
} catch (IOException e) {
LOG.error(String.format("An unexpected error occurred while retrieving photo from Google Directory API for: %s", workEmail), e);
return defaultPhoto;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.objectcomputing.checkins.services.memberprofile.memberphoto;

import io.micronaut.context.annotation.Property;
import io.micronaut.cache.CacheConfiguration;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
Expand All @@ -11,8 +11,11 @@
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Named;
import jakarta.validation.constraints.NotNull;

import java.time.Duration;

import static io.micronaut.http.HttpHeaders.CACHE_CONTROL;

@Controller("/services/member-profiles/member-photos")
Expand All @@ -22,12 +25,17 @@
@Tag(name = "member photo")
public class MemberPhotoController {

private final String expiry;
private final long expiry;
private final MemberPhotoService memberPhotoService;

public MemberPhotoController(@Property(name = "micronaut.caches.photo-cache.expire-after-write") String expiry,
MemberPhotoService memberPhotoService) {
this.expiry = expiry;
public MemberPhotoController(
@Named("photo-cache") CacheConfiguration cacheConfiguration,
MemberPhotoService memberPhotoService
) {
// If un-configured, default to 1 hour
this.expiry = cacheConfiguration.getExpireAfterWrite()
.map(Duration::toSeconds)
.orElseGet(() -> Duration.ofHours(1).toSeconds());
this.memberPhotoService = memberPhotoService;
}

Expand All @@ -41,6 +49,6 @@ public MemberPhotoController(@Property(name = "micronaut.caches.photo-cache.expi
public HttpResponse<byte[]> userImage(@NotNull String workEmail) {
byte[] photoData = memberPhotoService.getImageByEmailAddress(workEmail);
return HttpResponse.ok(photoData)
.header(CACHE_CONTROL, String.format("public, max-age=%s", expiry));
.header(CACHE_CONTROL, "public, max-age=%d".formatted(expiry));
}
}
Original file line number Diff line number Diff line change
@@ -1,73 +1,25 @@
package com.objectcomputing.checkins.services.memberprofile.memberphoto;

import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.HttpStatusCodes;
import com.google.api.services.directory.Directory;
import com.google.api.services.directory.model.UserPhoto;
import com.objectcomputing.checkins.util.googleapiaccess.GoogleApiAccess;
import io.micronaut.cache.annotation.CacheConfig;
import io.micronaut.cache.annotation.Cacheable;
import io.micronaut.context.env.Environment;
import jakarta.inject.Singleton;
import jakarta.validation.constraints.NotNull;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Base64;
import java.util.Optional;

@Singleton
@CacheConfig("photo-cache")
public class MemberPhotoServiceImpl implements MemberPhotoService {

private static final Logger LOG = LoggerFactory.getLogger(MemberPhotoServiceImpl.class);
private final GoogleApiAccess googleApiAccess;
private final byte[] defaultPhoto;
private final GooglePhotoAccessor googlePhotoAccessor;

public MemberPhotoServiceImpl(GoogleApiAccess googleApiAccess,
Environment environment) {
byte[] defaultPhoto = new byte[0];
this.googleApiAccess = googleApiAccess;
Optional<URL> resource = environment.getResource("public/default_profile.jpg");
try {
if(resource.isPresent()) {
URL defaultImageUrl = resource.get();
InputStream in = defaultImageUrl.openStream();
defaultPhoto = IOUtils.toByteArray(in);
}
} catch (IOException e) {
LOG.error("Error occurred while loading the default profile photo.", e);
}
this.defaultPhoto = defaultPhoto;
MemberPhotoServiceImpl(
GooglePhotoAccessor googlePhotoAccessor
) {
this.googlePhotoAccessor = googlePhotoAccessor;
}

@Override
@Cacheable
public byte[] getImageByEmailAddress(@NotNull String workEmail) {

byte[] photoData;

Directory directory = googleApiAccess.getDirectory();
try {
UserPhoto userPhoto = directory.users().photos().get(workEmail).execute();
photoData = Base64.getUrlDecoder().decode(userPhoto.getPhotoData());
LOG.debug(String.format("Photo data successfully retrieved from Google Directory API for: %s", workEmail));
} catch(GoogleJsonResponseException gjse) {
if(gjse.getStatusCode() == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
LOG.info(String.format("No photo was found for: %s", workEmail));
} else {
LOG.error(String.format("An unexpected error occurred while retrieving photo from Google Directory API for: %s", workEmail), gjse);
}
photoData = defaultPhoto;
} catch (IOException e) {
LOG.error(String.format("An unexpected error occurred while retrieving photo from Google Directory API for: %s", workEmail), e);
photoData = defaultPhoto;
}

return photoData;
return googlePhotoAccessor.getPhotoData(workEmail);
}
}
18 changes: 4 additions & 14 deletions server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ micronaut:
max-file-size: 100MB
caches:
photo-cache:
expire-after-write: 86400
expire-after-write: 1d # 1 day
member-cache:
expire-after-write: 300
heap:
max-entries: 600
expire-after-write: 300s # 5 minutes
maximum-size: 600
role-permission-cache:
expire-after-write: 86400
expire-after-write: 1d # 1 day

router:
static-resources:
Expand Down Expand Up @@ -126,15 +125,6 @@ service-account-credentials:
oauth_client_id: ${ OAUTH_CLIENT_ID }
oauth_client_secret: ${ OAUTH_CLIENT_SECRET }
---
ehcache:
caches:
photo-cache:
enabled: true
member-cache:
enabled: true
role-permission-cache:
enabled: true
---
mail-jet:
from_address: ${ FROM_ADDRESS }
from_name: ${ FROM_NAME }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.Base64;

import static com.objectcomputing.checkins.services.role.RoleType.Constants.MEMBER_ROLE;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class MemberPhotoControllerTest extends TestContainersSuite {
Expand All @@ -25,30 +28,33 @@ class MemberPhotoControllerTest extends TestContainersSuite {
private HttpClient client;

@Inject
private MemberPhotoService memberPhotoService;
GooglePhotoAccessor googlePhotoAccessor;

//Happy path
@Test
void testGetForValidInput() throws IOException {
void testGetForValidInput() {

String testEmail = "test@test.com";
String testPhotoData = "test.photo.data";
byte[] testData = Base64.getUrlEncoder().encode(testPhotoData.getBytes());
byte[] testData = Base64.getUrlEncoder().encode("test.photo.data".getBytes());

when(memberPhotoService.getImageByEmailAddress(testEmail)).thenReturn(testData);
when(googlePhotoAccessor.getPhotoData(testEmail)).thenReturn(testData);

final HttpRequest<?> request = HttpRequest.GET(String.format("/%s", testEmail)).basicAuth(MEMBER_ROLE, MEMBER_ROLE);
final HttpResponse<byte[]> response = client.toBlocking().exchange(request, byte[].class);
client.toBlocking().exchange(request, byte[].class);
client.toBlocking().exchange(request, byte[].class);

assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatus());
assertTrue(response.getBody().isPresent());
byte[] result = response.getBody().get();
assertEquals(new String(testData), new String(result));

// Only called once due to the cache
verify(googlePhotoAccessor, times(1)).getPhotoData(testEmail);
}

@MockBean(MemberPhotoServiceImpl.class)
public MemberPhotoService memberPhotoService() {
return mock(MemberPhotoService.class);
@MockBean(GooglePhotoAccessorImpl.class)
public GooglePhotoAccessor googlePhotoAccessor() {
return mock(GooglePhotoAccessor.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class MemberPhotoServiceImplTest extends TestContainersSuite {

Expand All @@ -47,7 +50,9 @@ class MemberPhotoServiceImplTest extends TestContainersSuite {
private Environment mockEnvironment;

@InjectMocks
private MemberPhotoServiceImpl services;
private GooglePhotoAccessorImpl accessor;

private MemberPhotoServiceImpl service;

private AutoCloseable mockFinalizer;

Expand All @@ -70,6 +75,7 @@ void resetMocks() {
reset(mockPhotos);
reset(mockGet);
reset(mockEnvironment);
service = new MemberPhotoServiceImpl(accessor);
}

// happy path
Expand All @@ -93,7 +99,7 @@ void testGetImageByEmailAddress() throws IOException {
when(mockPhotos.get(testEmail)).thenReturn(mockGet);
when(mockGet.execute()).thenReturn(testUserPhoto);

final byte[] result = services.getImageByEmailAddress(testEmail);
final byte[] result = service.getImageByEmailAddress(testEmail);

assertNotNull(result);
assertEquals(testPhotoData, new String(result, StandardCharsets.UTF_8));
Expand All @@ -111,7 +117,7 @@ void testDirectoryServiceThrowsGoogleJsonResponseException() throws IOException
when(mockPhotos.get(testEmail)).thenReturn(mockGet);
when(mockGet.execute()).thenThrow(GoogleJsonResponseException.class);

final byte[] result = services.getImageByEmailAddress(testEmail);
final byte[] result = service.getImageByEmailAddress(testEmail);

assertNotNull(result);
assertEquals("", new String(result, StandardCharsets.UTF_8));
Expand Down