DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Last call! Secure your stack and shape the future! Help dev teams across the globe navigate their software supply chain security challenges.

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workloads.

Releasing software shouldn't be stressful or risky. Learn how to leverage progressive delivery techniques to ensure safer deployments.

Avoid machine learning mistakes and boost model performance! Discover key ML patterns, anti-patterns, data strategies, and more.

Related

  • Component Tests for Spring Cloud Microservices
  • A New Era Of Spring Cloud
  • Distributed Tracing System (Spring Cloud Sleuth + OpenZipkin)
  • Spring Boot - Tracing Micro Service Logs | Log Tracing In Microservices With Spring Cloud Sleuth

Trending

  • Unlocking the Benefits of a Private API in AWS API Gateway
  • AI Meets Vector Databases: Redefining Data Retrieval in the Age of Intelligence
  • Unlocking AI Coding Assistants Part 4: Generate Spring Boot Application
  • Docker Base Images Demystified: A Practical Guide
  1. DZone
  2. Software Design and Architecture
  3. Microservices
  4. Microservices Architecture With Spring Boot and Spring Cloud

Microservices Architecture With Spring Boot and Spring Cloud

Learn more about building a microservices architecture with Spring Boot and Spring Cloud.

By 
Matt Raible user avatar
Matt Raible
·
Jul. 16, 19 · Tutorial
Likes (23)
Comment
Save
Tweet
Share
264.6K Views

Join the DZone community and get the full member experience.

Join For Free

Did you know that some of the largest tech companies, like Amazon and Google, are using Java to develop a microservices architecture? Many companies are building a microservices architecture to scale their people, but not their systems. If you’re also looking to do so, a good way to get started is to hire more Java developers (because there are so many of them). 

Within the Java ecosystem, you’ll find some well-rooted patterns for building microservice architectures. And if you’ve developed with Spring before, then Spring Boot and Spring Cloud should feel like a nice homecoming.

In this tutorial, I will show you how to build a Java microservices architecture with Spring Boot and Spring Cloud.

Create Java Microservices With Spring Cloud and Spring Boot

In most of my tutorials, I show you how to build everything from scratch. Today, I’d like to take a different approach and step through a pre-built example with you. Hopefully, this will be a bit shorter and easier to understand.

You can start by cloning the @oktadeveloper/java-microservices-examples repository.

git clone https://github.com/oktadeveloper/java-microservices-examples.git
cd java-microservices-examples/spring-boot+cloud


In the spring-boot+cloud directory, there are three projects:

  • discovery-service: a Netflix Eureka server, used for service discovery.
  •  car-service: a simple Car Service that uses Spring Data REST to serve up a REST API of cars.
  •  api-gateway: an API gateway that has a /cool-cars endpoint that talks to the car-service and filters out cars that aren’t cool (in my opinion, of course).

I created all of these applications using start.spring.io’s REST API and HTTPie.

http https://start.spring.io/starter.zip javaVersion==11 \
  artifactId==discovery-service name==eureka-service \
  dependencies==cloud-eureka-server baseDir==discovery-service | tar -xzvf -

http https://start.spring.io/starter.zip \
  artifactId==car-service name==car-service baseDir==car-service \
  dependencies==actuator,cloud-eureka,data-jpa,h2,data-rest,web,devtools,lombok | tar -xzvf -

http https://start.spring.io/starter.zip \
  artifactId==api-gateway name==api-gateway baseDir==api-gateway \
  dependencies==cloud-eureka,cloud-feign,data-rest,web,cloud-hystrix,lombok | tar -xzvf -


Spring Microservices With Java 11+

To make the discovery-service run on Java 11, I had to add a dependency on JAXB.

<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
</dependency>


The other two applications worked fine out-of-the-box on Java 11 with no changes in dependencies.

Java Service Discovery With Netflix Eureka

The discovery-service is configured the same as you would most Eureka servers. It as an @EnableEurekaServerannotation on its main class and properties that set its port and turn off discovery.

server.port=8761
eureka.client.register-with-eureka=false


The car-service and api-gateway projects are configured in a similar fashion. Both have a unique name defined and car-service is configured to run on port 8090 so it doesn’t conflict with 8080.

 car-service/src/main/resources/application.properties:

server.port=8090
spring.application.name=car-service


api-gateway/src/main/resources/application.properties:

spring.application.name=api-gateway


The main class in both projects is annotated with @EnableDiscoveryClient.

Build a Java Microservice With Spring Data REST

The car-service provides a REST API that lets you CRUD (Create, Read, Update, and Delete) cars. It creates a default set of cars when the application loads using an ApplicationRunner bean.

 car-service/src/main/java/com/example/carservice/CarServiceApplication.java:

package com.example.carservice;

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import java.util.stream.Stream;

@EnableDiscoveryClient
@SpringBootApplication
public class CarServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(CarServiceApplication.class, args);
    }

    @Bean
    ApplicationRunner init(CarRepository repository) {
        return args -> {
            Stream.of("Ferrari", "Jaguar", "Porsche", "Lamborghini", "Bugatti",
                    "AMC Gremlin", "Triumph Stag", "Ford Pinto", "Yugo GV").forEach(name -> {
                repository.save(new Car(name));
            });
            repository.findAll().forEach(System.out::println);
        };
    }
}

@Data
@NoArgsConstructor
@Entity
class Car {

    public Car(String name) {
        this.name = name;
    }

    @Id
    @GeneratedValue
    private Long id;

    @NonNull
    private String name;
}

@RepositoryRestResource
interface CarRepository extends JpaRepository<Car, Long> {
}


Spring Cloud + Feign and Hystrix in an API Gateway

Feign makes writing Java HTTP clients easier. Spring Cloud makes it possible to create a Feign client with just a few lines of code. Hystrix makes it possible to add failover capabilities to your Feign clients so they’re more resilient.

The api-gateway uses Feign and Hystrix to talk to the downstream car-service and failover to a fallback() method if it’s unavailable. It also exposes a /cool-cars endpoint that filters out cars you might not want to own.

 api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java:

package com.example.apigateway;

import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import lombok.Data;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.hateoas.Resources;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.Collection;
import java.util.stream.Collectors;

@EnableFeignClients
@EnableCircuitBreaker
@EnableDiscoveryClient
@EnableZuulProxy
@SpringBootApplication
public class ApiGatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayApplication.class, args);
    }
}

@Data
class Car {
    private String name;
}

@FeignClient("car-service")
interface CarClient {

    @GetMapping("/cars")
    @CrossOrigin
    Resources<Car> readCars();
}

@RestController
class CoolCarController {

    private final CarClient carClient;

    public CoolCarController(CarClient carClient) {
        this.carClient = carClient;
    }

    private Collection<Car> fallback() {
        return new ArrayList<>();
    }

    @GetMapping("/cool-cars")
    @CrossOrigin
    @HystrixCommand(fallbackMethod = "fallback")
    public Collection<Car> goodCars() {
        return carClient.readCars()
                .getContent()
                .stream()
                .filter(this::isCool)
                .collect(Collectors.toList());
    }

    private boolean isCool(Car car) {
        return !car.getName().equals("AMC Gremlin") &&
                !car.getName().equals("Triumph Stag") &&
                !car.getName().equals("Ford Pinto") &&
                !car.getName().equals("Yugo GV");
    }
}


Run a Java Microservices Architecture

If you run all of these services with ./mvnw in separate terminal windows, you can navigate to http://localhost:8761 and see they’ve registered with Eureka.

Eureka Server

If you navigate to http://localhost:8080/cool-bars in your browser, you’ll be redirected to Okta. What the?

Secure Java Microservices With OAuth 2.0 and OIDC

I’ve already configured security in this microservices architecture using OAuth 2.0 and OIDC. What’s the difference between the two? OIDC is an extension to OAuth 2.0 that provides identity. It also provides discovery so all the different OAuth 2.0 endpoints can be discovered from a single URL (called an issuer).

How did I configure security for all these microservices? I’m glad you asked!

I added Okta’s Spring Boot starter to the pom.xml in api-gateway and car-service:

<dependency>
    <groupId>com.okta.spring</groupId>
    <artifactId>okta-spring-boot-starter</artifactId>
    <version>1.2.0</version>
</dependency>


Then, I created a new OIDC app in Okta, configured with authorization code flow. You’ll need to complete the following steps if you want to see everything in action.

Create a Web Application in Okta

Log in to your Okta Developer account (or sign up if you don’t have an account).

  1. From the Applications page, choose Add Application.
  2. On the Create New Application page, select Web.
  3. Give your app a memorable name, add http://localhost:8080/login/oauth2/code/okta as a Login redirect URI, select Refresh Token (in addition to Authorization Code) and click Done.

Copy the issuer (found under API > Authorization Servers), client ID, and client secret into application.properties for both projects.

okta.oauth2.issuer=$issuer
okta.oauth2.client-id=$clientId
okta.oauth2.client-secret=$clientSecret


The Java code in the section below already exists, but I figured I’d explain it so you know what’s going on.

Configure Spring Security for OAuth 2.0 Login and Resource Server

In ApiGatewayApplication.java, I added Spring Security configuration to enable OAuth 2.0 login and enable the gateway as a resource server.

@Configuration
static class OktaOAuth2WebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .authorizeRequests().anyRequest().authenticated()
                .and()
            .oauth2Login()
                .and()
            .oauth2ResourceServer().jwt();
        // @formatter:on
    }
}


The resource server configuration is not used in this example, but I added it in case you wanted to hook up a mobile app or SPA to this gateway. If you’re using a SPA, you’ll also need to add a bean to configure CORS.

@Bean
public FilterRegistrationBean<CorsFilter> simpleCorsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.setAllowedOrigins(Collections.singletonList("*"));
    config.setAllowedMethods(Collections.singletonList("*"));
    config.setAllowedHeaders(Collections.singletonList("*"));
    source.registerCorsConfiguration("/**", config);
    FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return bean;
}


If you do use a CORS filter like this one, I recommend you change the origins, methods, and headers to be more specific, increasing security.


The CarServiceApplication.java is only configured as a resource server since it’s not expected to be accessed directly.

@Configuration
static class OktaOAuth2WebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .authorizeRequests().anyRequest().authenticated()
                .and()
            .oauth2ResourceServer().jwt();
        // @formatter:on
    }
}


To make it possible for the API gateway to access the Car Service, I created a UserFeignClientInterceptor.java in the API gateway project.

 api-gateway/src/main/java/com/example/apigateway/UserFeignClientInterceptor.java:

package com.example.apigateway;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Component;

@Component
public class UserFeignClientInterceptor implements RequestInterceptor {
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_TOKEN_TYPE = "Bearer";
    private final OAuth2AuthorizedClientService clientService;

    public UserFeignClientInterceptor(OAuth2AuthorizedClientService clientService) {
        this.clientService = clientService;
    }

    @Override
    public void apply(RequestTemplate template) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
        OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(
                oauthToken.getAuthorizedClientRegistrationId(),
                oauthToken.getName());

        OAuth2AccessToken accessToken = client.getAccessToken();
        template.header(AUTHORIZATION_HEADER, String.format("%s %s", BEARER_TOKEN_TYPE, accessToken.getTokenValue()));
    }
}


I configured it as a RequestInterceptor in ApiGatewayApplication.java:

@Bean
public RequestInterceptor getUserFeignClientInterceptor(OAuth2AuthorizedClientService clientService) {
    return new UserFeignClientInterceptor(clientService);
}


And, I added two properties in api-gateway/src/main/resources/application.properties so Feign is Spring Security-aware.

feign.hystrix.enabled=true
hystrix.shareSecurityContext=true


See Java Microservices Running With Security Enabled

Run all of the applications with ./mvnw in separate terminal windows, or in your IDE if you prefer.

To make it simpler to run in an IDE, there is an aggregator pom.xml in the root directory. If you’d installed IntelliJ IDEA’s command line launcher, you just need to run idea pom.xml.


Navigate to http://localhost:8080/cool-cars and you’ll be redirected to Okta to log in.

Okta Login

Enter the username and password for your Okta developer account and you should see a list of cool cars.

Image title

If you made it this far and got the examples apps running, congratulations! You’re super cool! 

Use Netflix Zuul and Spring Cloud to Proxy Routes

Another handy feature you might like in your microservices architecture is Netflix Zuul. Zuul is a gateway service that provides dynamic routing, monitoring, resiliency, and more.

To add Zuul, I added it as a dependency to api-gateway/pom.xml:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>


Then, I added @EnableZuulProxy to the ApiGatewayApplication class.

import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@EnableZuulProxy
@SpringBootApplication
public class ApiGatewayApplication {
    ...
}


To pass the access token to proxied routes, I created an AuthorizationHeaderFilter class that extends ZuulFilter.

package com.example.apigateway;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import org.springframework.core.Ordered;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;

import java.util.Optional;

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;

public class AuthorizationHeaderFilter extends ZuulFilter {

    private final OAuth2AuthorizedClientService clientService;

    public AuthorizationHeaderFilter(OAuth2AuthorizedClientService clientService) {
        this.clientService = clientService;
    }

    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        Optional<String> authorizationHeader = getAuthorizationHeader();
        authorizationHeader.ifPresent(s -> ctx.addZuulRequestHeader("Authorization", s));
        return null;
    }

    private Optional<String> getAuthorizationHeader() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
        OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(
                oauthToken.getAuthorizedClientRegistrationId(),
                oauthToken.getName());

        OAuth2AccessToken accessToken = client.getAccessToken();

        if (accessToken == null) {
            return Optional.empty();
        } else {
            String tokenType = accessToken.getTokenType().getValue();
            String authorizationHeaderValue = String.format("%s %s", tokenType, accessToken.getTokenValue());
            return Optional.of(authorizationHeaderValue);
        }
    }
}


You might notice that there’s code in the getAuthorizationHeader() method that’s very similar to the code that’s in UserFeignClientInterceptor. Since it’s only a few lines, I opted not to move these to a utility class. 


To make Spring Boot and Zuul aware of this filter, I registered it as a bean in the main application class.

public AuthorizationHeaderFilter authHeaderFilter(OAuth2AuthorizedClientService clientService) {
    return new AuthorizationHeaderFilter(clientService);
}


To proxy requests from the API Gateway to the Car Service, I added routes to api-gateway/src/main/resources/application.properties.

zuul.routes.car-service.path=/cars
zuul.routes.car-service.url=http://localhost:8090

zuul.routes.home.path=/home
zuul.routes.home.url=http://localhost:8090

zuul.sensitive-headers=Cookie,Set-Cookie


I added a HomeController to the car-service project for the /home route.

package com.example.carservice;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;

@RestController
public class HomeController {

    private final static Logger log = LoggerFactory.getLogger(HomeController.class);

    @GetMapping("/home")
    public String howdy(Principal principal) {
        String username = principal.getName();
        JwtAuthenticationToken token = (JwtAuthenticationToken) principal;
        log.info("claims: " + token.getTokenAttributes());
        return "Hello, " + username;
    }
}

Confirm Your Zuul Routes Work

Since these changes are already in the project you cloned, you should be able to view https://localhost:8080/cars and http://localhost:8080/home in your browser.

Image title

What About Spring Cloud Config?

One of the things you might’ve noticed in this example is you had to configure the OIDC properties in each application. This could be a real pain if you had 500 microservices. Yes, you could define them as environment variables and this would solve the problem. However, if you have different microservices stacks using different OIDC client IDs, this approach will be difficult.

Spring Cloud Config is a project that provides externalized configuration for distributed systems. Rather than adding it to this example, I’ll cover it in a future tutorial.

What About Kotlin?

I wrote this post with Java because it’s the most popular language in the Java ecosystem. However, Kotlin is on the rise, according to RedMonk’s programming language rankings from January 2019.

For this quarter, at least, Kotlin grew substantially while all three of its fellow JVM-based counterparts declined. Kotlin jumped so far, in fact, that it finally broke into the Top 20 at #20 and leapfrogged Clojure (#24) and Groovy (#24) while doing so. It’s still well behind Scala (#13), but Kotlin’s growth has been second only to Swift in this history of these rankings so it will be interesting to see what lies ahead in the next run or two.

Spring has excellent support for Kotlin, and you can choose it as a language on start.spring.io. If you’d like to see us write more posts using Kotlin, please let us know in the comments!

Known Issues With Refresh Tokens

By default, Okta’s access tokens expire after one hour. This is expected, and short-lived access tokens are recommended when using OAuth 2.0. Refresh tokens typically live a lot longer — think days or months — and can be used to get new access tokens. This should happen automatically when using Okta’s Spring Boot starter, but it does not.

I configured my Okta org so its access tokens expire in five minutes. You can do this by going to API > Authorization Servers > Access Policies, click on the Default Policy, and edit its rule. Then change the access token lifetime from 1 hour to 5 minutes.

Hit http://localhost:8080/cool-cars in your browser and you’ll be redirected to Okta to login. Log in and you should see a JSON string of cars.

Go do something else for more than 5 minutes.

Come back, refresh your browser and you’ll see [] instead of all the cars.

I’m still working on a solution to this and will update this post once I find one. If you happen to know of a solution, please let me know!

Update: Spring Security 5.1 doesn’t yet automatically refresh the OAuth access token. It should be available in Spring Security 5.2.

Have More Fun With Spring Boot, Spring Cloud, and Microservices

I hope you liked this tour of how to build Java microservice architectures with Spring Boot and Spring Cloud. You learned how to build everything with minimal code, then configure it to be secure with Spring Security, OAuth 2.0, and Okta.

You can find all the code shown in this tutorial on GitHub.

Java Microservices with Spring Boot and Spring Cloud was originally published on the Okta Developer Blog on May 22, 2019. 

Spring Framework Spring Cloud Spring Boot microservice Architecture Spring Security

Published at DZone with permission of Matt Raible, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Component Tests for Spring Cloud Microservices
  • A New Era Of Spring Cloud
  • Distributed Tracing System (Spring Cloud Sleuth + OpenZipkin)
  • Spring Boot - Tracing Micro Service Logs | Log Tracing In Microservices With Spring Cloud Sleuth

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!