initial commit
Change-Id: I12a20fc994c2a94df96de9d3393b06bf6687f77a
44
src/adservice/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Copyright 2020 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Define a default value so it's not empty if the builder fails to provide it
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
|
||||
FROM --platform=$BUILDPLATFORM eclipse-temurin:24.0.2_12-jdk-noble@sha256:dacac8e9a0df0d2bd24e702b4431132875c249930b70555ebd7ca285b5bee684 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ["build.gradle", "gradlew", "./"]
|
||||
COPY gradle gradle
|
||||
RUN chmod +x gradlew
|
||||
RUN ./gradlew downloadRepos
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x gradlew
|
||||
RUN ./gradlew installDist
|
||||
|
||||
FROM eclipse-temurin:25.0.2_10-jre-alpine@sha256:f10d6259d0798c1e12179b6bf3b63cea0d6843f7b09c9f9c9c422c50e44379ec
|
||||
|
||||
# @TODO: https://github.com/GoogleCloudPlatform/microservices-demo/issues/2517
|
||||
# Download Stackdriver Profiler Java agent
|
||||
# RUN mkdir -p /opt/cprof && \
|
||||
# wget -q -O- https://storage.googleapis.com/cloud-profiler/java/latest/profiler_java_agent_alpine.tar.gz \
|
||||
# | tar xzv -C /opt/cprof && \
|
||||
# rm -rf profiler_java_agent.tar.gz
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app .
|
||||
|
||||
EXPOSE 9555
|
||||
ENTRYPOINT ["/app/build/install/hipstershop/bin/AdService"]
|
||||
28
src/adservice/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Ad Service
|
||||
|
||||
The Ad service provides advertisement based on context keys. If no context keys are provided then it returns random ads.
|
||||
|
||||
## Building locally
|
||||
|
||||
The Ad service uses gradlew to compile/install/distribute. Gradle wrapper is already part of the source code. To build Ad Service, run:
|
||||
|
||||
```
|
||||
./gradlew installDist
|
||||
```
|
||||
It will create executable script src/adservice/build/install/hipstershop/bin/AdService
|
||||
|
||||
### Upgrading gradle version
|
||||
If you need to upgrade the version of gradle then run
|
||||
|
||||
```
|
||||
./gradlew wrapper --gradle-version <new-version>
|
||||
```
|
||||
|
||||
## Building docker image
|
||||
|
||||
From `src/adservice/`, run:
|
||||
|
||||
```
|
||||
docker build ./
|
||||
```
|
||||
|
||||
119
src/adservice/build.gradle
Normal file
@@ -0,0 +1,119 @@
|
||||
plugins {
|
||||
id 'com.google.protobuf' version '0.9.6'
|
||||
id 'com.github.sherter.google-java-format' version '0.9'
|
||||
id 'idea'
|
||||
id 'application'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
}
|
||||
|
||||
description = 'Ad Service'
|
||||
group = "adservice"
|
||||
version = "0.1.0-SNAPSHOT"
|
||||
|
||||
def grpcVersion = "1.79.0"
|
||||
def jacksonCoreVersion = "2.21.1"
|
||||
def jacksonDatabindVersion = "2.21.1"
|
||||
def protocVersion = "4.34.0"
|
||||
|
||||
tasks.withType(JavaCompile) {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
ext {
|
||||
speed = project.hasProperty('speed') ? project.getProperty('speed') : false
|
||||
offlineCompile = new File("$buildDir/output/lib")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (speed) {
|
||||
implementation fileTree(dir: offlineCompile, include: '*.jar')
|
||||
} else {
|
||||
implementation "com.google.api.grpc:proto-google-common-protos:2.66.0",
|
||||
"javax.annotation:javax.annotation-api:1.3.2",
|
||||
"io.grpc:grpc-protobuf:${grpcVersion}",
|
||||
"io.grpc:grpc-stub:${grpcVersion}",
|
||||
"io.grpc:grpc-netty:${grpcVersion}",
|
||||
"io.grpc:grpc-services:${grpcVersion}",
|
||||
"io.grpc:grpc-census:${grpcVersion}",
|
||||
"org.apache.logging.log4j:log4j-core:2.25.3",
|
||||
"com.google.protobuf:protobuf-java:${protocVersion}"
|
||||
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jacksonCoreVersion}",
|
||||
"com.fasterxml.jackson.core:jackson-databind:${jacksonDatabindVersion}",
|
||||
"io.netty:netty-tcnative-boringssl-static:2.0.75.Final"
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:${protocVersion}"
|
||||
}
|
||||
plugins {
|
||||
grpc {
|
||||
artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
|
||||
}
|
||||
}
|
||||
generateProtoTasks {
|
||||
all()*.plugins {
|
||||
grpc {}
|
||||
}
|
||||
ofSourceSet('main')
|
||||
}
|
||||
}
|
||||
|
||||
googleJavaFormat {
|
||||
toolVersion '1.35.0'
|
||||
}
|
||||
|
||||
// Inform IDEs like IntelliJ IDEA, Eclipse or NetBeans about the generated code.
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDirs 'hipstershop'
|
||||
srcDirs 'build/generated/source/proto/main/java/hipstershop'
|
||||
srcDirs 'build/generated/source/proto/main/grpc/hipstershop'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startScripts.enabled = false
|
||||
|
||||
// This to cache dependencies during Docker image building. First build will take time.
|
||||
// Subsequent build will be incremental.
|
||||
task downloadRepos(type: Copy) {
|
||||
from configurations.compileClasspath
|
||||
into offlineCompile
|
||||
from configurations.compileClasspath
|
||||
into offlineCompile
|
||||
}
|
||||
|
||||
task adService(type: CreateStartScripts) {
|
||||
mainClass.set('hipstershop.AdService')
|
||||
applicationName = 'AdService'
|
||||
outputDir = new File(project.buildDir, 'tmp')
|
||||
classpath = startScripts.classpath
|
||||
// @TODO: https://github.com/GoogleCloudPlatform/microservices-demo/issues/2517
|
||||
// defaultJvmOpts =
|
||||
// ["-agentpath:/opt/cprof/profiler_java_agent.so=-cprof_service=adservice,-cprof_service_version=1.0.0"]
|
||||
}
|
||||
|
||||
task adServiceClient(type: CreateStartScripts) {
|
||||
mainClass.set('hipstershop.AdServiceClient')
|
||||
applicationName = 'AdServiceClient'
|
||||
outputDir = new File(project.buildDir, 'tmp')
|
||||
classpath = startScripts.classpath
|
||||
// @TODO: https://github.com/GoogleCloudPlatform/microservices-demo/issues/2517
|
||||
// defaultJvmOpts =
|
||||
// ["-agentpath:/opt/cprof/profiler_java_agent.so=-cprof_service=adserviceclient,-cprof_service_version=1.0.0"]
|
||||
}
|
||||
|
||||
applicationDistribution.into('bin') {
|
||||
from(adService)
|
||||
from(adServiceClient)
|
||||
fileMode = 0755
|
||||
}
|
||||
23
src/adservice/genproto.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash -eu
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# [START gke_adservice_genproto]
|
||||
# protos are needed in adservice folder for compiling during Docker build.
|
||||
|
||||
mkdir -p proto && \
|
||||
cp ../../protos/demo.proto src/main/proto
|
||||
|
||||
# [END gke_adservice_genproto]
|
||||
BIN
src/adservice/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
src/adservice/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
src/adservice/gradlew
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
src/adservice/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
src/adservice/settings.gradle
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = 'hipstershop'
|
||||
238
src/adservice/src/main/java/hipstershop/AdService.java
Normal file
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright 2018, Google LLC.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package hipstershop;
|
||||
|
||||
import com.google.common.collect.ImmutableListMultimap;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.Iterables;
|
||||
import hipstershop.Demo.Ad;
|
||||
import hipstershop.Demo.AdRequest;
|
||||
import hipstershop.Demo.AdResponse;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.ServerBuilder;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.services.*;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
public final class AdService {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(AdService.class);
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private static int MAX_ADS_TO_SERVE = 2;
|
||||
|
||||
private Server server;
|
||||
private HealthStatusManager healthMgr;
|
||||
|
||||
private static final AdService service = new AdService();
|
||||
|
||||
private void start() throws IOException {
|
||||
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "9555"));
|
||||
healthMgr = new HealthStatusManager();
|
||||
|
||||
server =
|
||||
ServerBuilder.forPort(port)
|
||||
.addService(new AdServiceImpl())
|
||||
.addService(healthMgr.getHealthService())
|
||||
.build()
|
||||
.start();
|
||||
logger.info("Ad Service started, listening on " + port);
|
||||
Runtime.getRuntime()
|
||||
.addShutdownHook(
|
||||
new Thread(
|
||||
() -> {
|
||||
// Use stderr here since the logger may have been reset by its JVM shutdown hook.
|
||||
System.err.println(
|
||||
"*** shutting down gRPC ads server since JVM is shutting down");
|
||||
AdService.this.stop();
|
||||
System.err.println("*** server shut down");
|
||||
}));
|
||||
healthMgr.setStatus("", ServingStatus.SERVING);
|
||||
}
|
||||
|
||||
private void stop() {
|
||||
if (server != null) {
|
||||
healthMgr.clearStatus("");
|
||||
server.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static class AdServiceImpl extends hipstershop.AdServiceGrpc.AdServiceImplBase {
|
||||
|
||||
/**
|
||||
* Retrieves ads based on context provided in the request {@code AdRequest}.
|
||||
*
|
||||
* @param req the request containing context.
|
||||
* @param responseObserver the stream observer which gets notified with the value of {@code
|
||||
* AdResponse}
|
||||
*/
|
||||
@Override
|
||||
public void getAds(AdRequest req, StreamObserver<AdResponse> responseObserver) {
|
||||
AdService service = AdService.getInstance();
|
||||
try {
|
||||
List<Ad> allAds = new ArrayList<>();
|
||||
logger.info("received ad request (context_words=" + req.getContextKeysList() + ")");
|
||||
if (req.getContextKeysCount() > 0) {
|
||||
for (int i = 0; i < req.getContextKeysCount(); i++) {
|
||||
Collection<Ad> ads = service.getAdsByCategory(req.getContextKeys(i));
|
||||
allAds.addAll(ads);
|
||||
}
|
||||
} else {
|
||||
allAds = service.getRandomAds();
|
||||
}
|
||||
if (allAds.isEmpty()) {
|
||||
// Serve random ads.
|
||||
allAds = service.getRandomAds();
|
||||
}
|
||||
AdResponse reply = AdResponse.newBuilder().addAllAds(allAds).build();
|
||||
responseObserver.onNext(reply);
|
||||
responseObserver.onCompleted();
|
||||
} catch (StatusRuntimeException e) {
|
||||
logger.log(Level.WARN, "GetAds Failed with status {}", e.getStatus());
|
||||
responseObserver.onError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final ImmutableListMultimap<String, Ad> adsMap = createAdsMap();
|
||||
|
||||
private Collection<Ad> getAdsByCategory(String category) {
|
||||
return adsMap.get(category);
|
||||
}
|
||||
|
||||
private static final Random random = new Random();
|
||||
|
||||
private List<Ad> getRandomAds() {
|
||||
List<Ad> ads = new ArrayList<>(MAX_ADS_TO_SERVE);
|
||||
Collection<Ad> allAds = adsMap.values();
|
||||
for (int i = 0; i < MAX_ADS_TO_SERVE; i++) {
|
||||
ads.add(Iterables.get(allAds, random.nextInt(allAds.size())));
|
||||
}
|
||||
return ads;
|
||||
}
|
||||
|
||||
private static AdService getInstance() {
|
||||
return service;
|
||||
}
|
||||
|
||||
/** Await termination on the main thread since the grpc library uses daemon threads. */
|
||||
private void blockUntilShutdown() throws InterruptedException {
|
||||
if (server != null) {
|
||||
server.awaitTermination();
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableListMultimap<String, Ad> createAdsMap() {
|
||||
Ad hairdryer =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/2ZYFJ3GM2N")
|
||||
.setText("Hairdryer for sale. 50% off.")
|
||||
.build();
|
||||
Ad tankTop =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/66VCHSJNUP")
|
||||
.setText("Tank top for sale. 20% off.")
|
||||
.build();
|
||||
Ad candleHolder =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/0PUK6V6EV0")
|
||||
.setText("Candle holder for sale. 30% off.")
|
||||
.build();
|
||||
Ad bambooGlassJar =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/9SIQT8TOJO")
|
||||
.setText("Bamboo glass jar for sale. 10% off.")
|
||||
.build();
|
||||
Ad watch =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/1YMWWN1N4O")
|
||||
.setText("Watch for sale. Buy one, get second kit for free")
|
||||
.build();
|
||||
Ad mug =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/6E92ZMYYFZ")
|
||||
.setText("Mug for sale. Buy two, get third one for free")
|
||||
.build();
|
||||
Ad loafers =
|
||||
Ad.newBuilder()
|
||||
.setRedirectUrl("/product/L9ECAV7KIM")
|
||||
.setText("Loafers for sale. Buy one, get second one for free")
|
||||
.build();
|
||||
return ImmutableListMultimap.<String, Ad>builder()
|
||||
.putAll("clothing", tankTop)
|
||||
.putAll("accessories", watch)
|
||||
.putAll("footwear", loafers)
|
||||
.putAll("hair", hairdryer)
|
||||
.putAll("decor", candleHolder)
|
||||
.putAll("kitchen", bambooGlassJar, mug)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void initStats() {
|
||||
if (System.getenv("DISABLE_STATS") != null) {
|
||||
logger.info("Stats disabled.");
|
||||
return;
|
||||
}
|
||||
logger.info("Stats enabled, but temporarily unavailable");
|
||||
|
||||
long sleepTime = 10; /* seconds */
|
||||
int maxAttempts = 5;
|
||||
|
||||
// TODO(arbrown) Implement OpenTelemetry stats
|
||||
|
||||
}
|
||||
|
||||
private static void initTracing() {
|
||||
if (System.getenv("DISABLE_TRACING") != null) {
|
||||
logger.info("Tracing disabled.");
|
||||
return;
|
||||
}
|
||||
logger.info("Tracing enabled but temporarily unavailable");
|
||||
logger.info("See https://github.com/GoogleCloudPlatform/microservices-demo/issues/422 for more info.");
|
||||
|
||||
// TODO(arbrown) Implement OpenTelemetry tracing
|
||||
|
||||
logger.info("Tracing enabled - Stackdriver exporter initialized.");
|
||||
}
|
||||
|
||||
/** Main launches the server from the command line. */
|
||||
public static void main(String[] args) throws IOException, InterruptedException {
|
||||
|
||||
new Thread(
|
||||
() -> {
|
||||
initStats();
|
||||
initTracing();
|
||||
})
|
||||
.start();
|
||||
|
||||
// Start the RPC server. You shouldn't see any output from gRPC before this.
|
||||
logger.info("AdService starting.");
|
||||
final AdService service = AdService.getInstance();
|
||||
service.start();
|
||||
service.blockUntilShutdown();
|
||||
}
|
||||
}
|
||||
116
src/adservice/src/main/java/hipstershop/AdServiceClient.java
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2018, Google LLC.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package hipstershop;
|
||||
|
||||
import hipstershop.Demo.Ad;
|
||||
import hipstershop.Demo.AdRequest;
|
||||
import hipstershop.Demo.AdResponse;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/** A simple client that requests ads from the Ads Service. */
|
||||
public class AdServiceClient {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(AdServiceClient.class);
|
||||
|
||||
private final ManagedChannel channel;
|
||||
private final hipstershop.AdServiceGrpc.AdServiceBlockingStub blockingStub;
|
||||
|
||||
/** Construct client connecting to Ad Service at {@code host:port}. */
|
||||
private AdServiceClient(String host, int port) {
|
||||
this(
|
||||
ManagedChannelBuilder.forAddress(host, port)
|
||||
// Channels are secure by default (via SSL/TLS). For the example we disable TLS to avoid
|
||||
// needing certificates.
|
||||
.usePlaintext()
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Construct client for accessing RouteGuide server using the existing channel. */
|
||||
private AdServiceClient(ManagedChannel channel) {
|
||||
this.channel = channel;
|
||||
blockingStub = hipstershop.AdServiceGrpc.newBlockingStub(channel);
|
||||
}
|
||||
|
||||
private void shutdown() throws InterruptedException {
|
||||
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** Get Ads from Server. */
|
||||
public void getAds(String contextKey) {
|
||||
logger.info("Get Ads with context " + contextKey + " ...");
|
||||
AdRequest request = AdRequest.newBuilder().addContextKeys(contextKey).build();
|
||||
AdResponse response;
|
||||
|
||||
try {
|
||||
response = blockingStub.getAds(request);
|
||||
} catch (StatusRuntimeException e) {
|
||||
logger.log(Level.WARN, "RPC failed: " + e.getStatus());
|
||||
return;
|
||||
}
|
||||
for (Ad ads : response.getAdsList()) {
|
||||
logger.info("Ads: " + ads.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private static int getPortOrDefaultFromArgs(String[] args) {
|
||||
int portNumber = 9555;
|
||||
if (2 < args.length) {
|
||||
try {
|
||||
portNumber = Integer.parseInt(args[2]);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn(String.format("Port %s is invalid, use default port %d.", args[2], 9555));
|
||||
}
|
||||
}
|
||||
return portNumber;
|
||||
}
|
||||
|
||||
private static String getStringOrDefaultFromArgs(
|
||||
String[] args, int index, @Nullable String defaultString) {
|
||||
String s = defaultString;
|
||||
if (index < args.length) {
|
||||
s = args[index];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ads Service Client main. If provided, the first element of {@code args} is the context key to
|
||||
* get the ads from the Ads Service
|
||||
*/
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
// Add final keyword to pass checkStyle.
|
||||
final String contextKeys = getStringOrDefaultFromArgs(args, 0, "camera");
|
||||
final String host = getStringOrDefaultFromArgs(args, 1, "localhost");
|
||||
final int serverPort = getPortOrDefaultFromArgs(args);
|
||||
|
||||
AdServiceClient client = new AdServiceClient(host, serverPort);
|
||||
try {
|
||||
client.getAds(contextKeys);
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
|
||||
logger.info("Exiting AdServiceClient...");
|
||||
}
|
||||
}
|
||||
260
src/adservice/src/main/proto/demo.proto
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright 2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package hipstershop;
|
||||
|
||||
// -----------------Cart service-----------------
|
||||
|
||||
service CartService {
|
||||
rpc AddItem(AddItemRequest) returns (Empty) {}
|
||||
rpc GetCart(GetCartRequest) returns (Cart) {}
|
||||
rpc EmptyCart(EmptyCartRequest) returns (Empty) {}
|
||||
}
|
||||
|
||||
message CartItem {
|
||||
string product_id = 1;
|
||||
int32 quantity = 2;
|
||||
}
|
||||
|
||||
message AddItemRequest {
|
||||
string user_id = 1;
|
||||
CartItem item = 2;
|
||||
}
|
||||
|
||||
message EmptyCartRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message GetCartRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message Cart {
|
||||
string user_id = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
|
||||
// ---------------Recommendation service----------
|
||||
|
||||
service RecommendationService {
|
||||
rpc ListRecommendations(ListRecommendationsRequest) returns (ListRecommendationsResponse){}
|
||||
}
|
||||
|
||||
message ListRecommendationsRequest {
|
||||
string user_id = 1;
|
||||
repeated string product_ids = 2;
|
||||
}
|
||||
|
||||
message ListRecommendationsResponse {
|
||||
repeated string product_ids = 1;
|
||||
}
|
||||
|
||||
// ---------------Product Catalog----------------
|
||||
|
||||
service ProductCatalogService {
|
||||
rpc ListProducts(Empty) returns (ListProductsResponse) {}
|
||||
rpc GetProduct(GetProductRequest) returns (Product) {}
|
||||
rpc SearchProducts(SearchProductsRequest) returns (SearchProductsResponse) {}
|
||||
}
|
||||
|
||||
message Product {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string description = 3;
|
||||
string picture = 4;
|
||||
Money price_usd = 5;
|
||||
|
||||
// Categories such as "clothing" or "kitchen" that can be used to look up
|
||||
// other related products.
|
||||
repeated string categories = 6;
|
||||
}
|
||||
|
||||
message ListProductsResponse {
|
||||
repeated Product products = 1;
|
||||
}
|
||||
|
||||
message GetProductRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message SearchProductsRequest {
|
||||
string query = 1;
|
||||
}
|
||||
|
||||
message SearchProductsResponse {
|
||||
repeated Product results = 1;
|
||||
}
|
||||
|
||||
// ---------------Shipping Service----------
|
||||
|
||||
service ShippingService {
|
||||
rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse) {}
|
||||
rpc ShipOrder(ShipOrderRequest) returns (ShipOrderResponse) {}
|
||||
}
|
||||
|
||||
message GetQuoteRequest {
|
||||
Address address = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message GetQuoteResponse {
|
||||
Money cost_usd = 1;
|
||||
}
|
||||
|
||||
message ShipOrderRequest {
|
||||
Address address = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message ShipOrderResponse {
|
||||
string tracking_id = 1;
|
||||
}
|
||||
|
||||
message Address {
|
||||
string street_address = 1;
|
||||
string city = 2;
|
||||
string state = 3;
|
||||
string country = 4;
|
||||
int32 zip_code = 5;
|
||||
}
|
||||
|
||||
// -----------------Currency service-----------------
|
||||
|
||||
service CurrencyService {
|
||||
rpc GetSupportedCurrencies(Empty) returns (GetSupportedCurrenciesResponse) {}
|
||||
rpc Convert(CurrencyConversionRequest) returns (Money) {}
|
||||
}
|
||||
|
||||
// Represents an amount of money with its currency type.
|
||||
message Money {
|
||||
// The 3-letter currency code defined in ISO 4217.
|
||||
string currency_code = 1;
|
||||
|
||||
// The whole units of the amount.
|
||||
// For example if `currencyCode` is `"USD"`, then 1 unit is one US dollar.
|
||||
int64 units = 2;
|
||||
|
||||
// Number of nano (10^-9) units of the amount.
|
||||
// The value must be between -999,999,999 and +999,999,999 inclusive.
|
||||
// If `units` is positive, `nanos` must be positive or zero.
|
||||
// If `units` is zero, `nanos` can be positive, zero, or negative.
|
||||
// If `units` is negative, `nanos` must be negative or zero.
|
||||
// For example $-1.75 is represented as `units`=-1 and `nanos`=-750,000,000.
|
||||
int32 nanos = 3;
|
||||
}
|
||||
|
||||
message GetSupportedCurrenciesResponse {
|
||||
// The 3-letter currency code defined in ISO 4217.
|
||||
repeated string currency_codes = 1;
|
||||
}
|
||||
|
||||
message CurrencyConversionRequest {
|
||||
Money from = 1;
|
||||
|
||||
// The 3-letter currency code defined in ISO 4217.
|
||||
string to_code = 2;
|
||||
}
|
||||
|
||||
// -------------Payment service-----------------
|
||||
|
||||
service PaymentService {
|
||||
rpc Charge(ChargeRequest) returns (ChargeResponse) {}
|
||||
}
|
||||
|
||||
message CreditCardInfo {
|
||||
string credit_card_number = 1;
|
||||
int32 credit_card_cvv = 2;
|
||||
int32 credit_card_expiration_year = 3;
|
||||
int32 credit_card_expiration_month = 4;
|
||||
}
|
||||
|
||||
message ChargeRequest {
|
||||
Money amount = 1;
|
||||
CreditCardInfo credit_card = 2;
|
||||
}
|
||||
|
||||
message ChargeResponse {
|
||||
string transaction_id = 1;
|
||||
}
|
||||
|
||||
// -------------Email service-----------------
|
||||
|
||||
service EmailService {
|
||||
rpc SendOrderConfirmation(SendOrderConfirmationRequest) returns (Empty) {}
|
||||
}
|
||||
|
||||
message OrderItem {
|
||||
CartItem item = 1;
|
||||
Money cost = 2;
|
||||
}
|
||||
|
||||
message OrderResult {
|
||||
string order_id = 1;
|
||||
string shipping_tracking_id = 2;
|
||||
Money shipping_cost = 3;
|
||||
Address shipping_address = 4;
|
||||
repeated OrderItem items = 5;
|
||||
}
|
||||
|
||||
message SendOrderConfirmationRequest {
|
||||
string email = 1;
|
||||
OrderResult order = 2;
|
||||
}
|
||||
|
||||
|
||||
// -------------Checkout service-----------------
|
||||
|
||||
service CheckoutService {
|
||||
rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse) {}
|
||||
}
|
||||
|
||||
message PlaceOrderRequest {
|
||||
string user_id = 1;
|
||||
string user_currency = 2;
|
||||
|
||||
Address address = 3;
|
||||
string email = 5;
|
||||
CreditCardInfo credit_card = 6;
|
||||
}
|
||||
|
||||
message PlaceOrderResponse {
|
||||
OrderResult order = 1;
|
||||
}
|
||||
|
||||
// ------------Ad service------------------
|
||||
|
||||
service AdService {
|
||||
rpc GetAds(AdRequest) returns (AdResponse) {}
|
||||
}
|
||||
|
||||
message AdRequest {
|
||||
// List of important key words from the current page describing the context.
|
||||
repeated string context_keys = 1;
|
||||
}
|
||||
|
||||
message AdResponse {
|
||||
repeated Ad ads = 1;
|
||||
}
|
||||
|
||||
message Ad {
|
||||
// url to redirect to when an ad is clicked.
|
||||
string redirect_url = 1;
|
||||
|
||||
// short advertisement text to display.
|
||||
string text = 2;
|
||||
}
|
||||
45
src/adservice/src/main/resources/log4j2.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2020 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="STDOUT" target="SYSTEM_OUT">
|
||||
|
||||
<!-- This is a JSON format that can be read by the Stackdriver Logging agent. The trace ID,
|
||||
span ID, sampling decision, and timestamp are interpreted by Stackdriver. It uses the
|
||||
special JSON keys that the Stackdriver Logging agent converts to "trace", "spanId",
|
||||
"traceSampled", and "timestamp" in the Stackdriver LogEntry
|
||||
(https://cloud.google.com/logging/docs/agent/configuration#special-fields). -->
|
||||
|
||||
<JsonLayout compact="true" eventEol="true">
|
||||
<KeyValuePair key="logging.googleapis.com/trace" value="$${ctx:traceId}"/>
|
||||
<KeyValuePair key="logging.googleapis.com/spanId" value="$${ctx:spanId}"/>
|
||||
<KeyValuePair key="logging.googleapis.com/traceSampled" value="$${ctx:traceSampled}"/>
|
||||
<KeyValuePair key="time" value="$${date:yyyy-MM-dd}T$${date:HH:mm:ss.SSS}Z"/>
|
||||
</JsonLayout>
|
||||
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Logger name="io.grpc.netty" level="INFO"/>
|
||||
<Logger name="io.netty" level="INFO"/>
|
||||
<Logger name="sun.net" level="INFO"/>
|
||||
<Root level="TRACE">
|
||||
<AppenderRef ref="STDOUT"/>
|
||||
</Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
48
src/cartservice/cartservice.sln
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.26124.0
|
||||
MinimumVisualStudioVersion = 15.0.26124.0
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cartservice", "src\cartservice.csproj", "{2348C29F-E8D3-4955-916D-D609CBC97FCB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cartservice.tests", "tests\cartservice.tests.csproj", "{59825342-CE64-4AFA-8744-781692C0811B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{59825342-CE64-4AFA-8744-781692C0811B}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
6
src/cartservice/src/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
**/*.sh
|
||||
**/*.bat
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/out/
|
||||
Dockerfile*
|
||||
44
src/cartservice/src/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Copyright 2021 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Define a default value so it's not empty if the builder fails to provide it
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
|
||||
# https://mcr.microsoft.com/product/dotnet/sdk
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.100-noble@sha256:c7445f141c04f1a6b454181bd098dcfa606c61ba0bd213d0a702489e5bd4cd71 AS builder
|
||||
ARG TARGETARCH=amd64
|
||||
WORKDIR /app
|
||||
COPY cartservice.csproj .
|
||||
RUN dotnet restore cartservice.csproj \
|
||||
-a $TARGETARCH
|
||||
COPY . .
|
||||
RUN dotnet publish cartservice.csproj \
|
||||
-p:PublishSingleFile=true \
|
||||
-a $TARGETARCH \
|
||||
--self-contained true \
|
||||
-p:PublishTrimmed=true \
|
||||
-p:TrimMode=full \
|
||||
-c release \
|
||||
-o /cartservice
|
||||
|
||||
# https://mcr.microsoft.com/product/dotnet/runtime-deps
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.0-noble-chiseled@sha256:b857c8cb8d929183cfe4c6dd9994abba92a2639dd2dbaf06005379f815991604
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /cartservice .
|
||||
EXPOSE 7070
|
||||
ENV DOTNET_EnableDiagnostics=0 \
|
||||
ASPNETCORE_HTTP_PORTS=7070
|
||||
USER 1000
|
||||
ENTRYPOINT ["/app/cartservice"]
|
||||
33
src/cartservice/src/Dockerfile.debug
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright 2021 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:478b9038d187e5b5c29bfa8173ded5d29e864b5ad06102a12106380ee01e2e49 AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN dotnet restore cartservice.csproj
|
||||
RUN dotnet build "./cartservice.csproj" -c Debug -o /out
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish cartservice.csproj -c Debug -o /out
|
||||
|
||||
# Building final image used in running container
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0@sha256:a04d1c1d2d26119049494057d80ea6cda25bbd8aef7c444a1fc1ef874fd3955b AS final
|
||||
# Installing procps on the container to enable debugging of .NET Core
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y unzip procps wget
|
||||
WORKDIR /app
|
||||
COPY --from=publish /out .
|
||||
ENV ASPNETCORE_HTTP_PORTS=7070
|
||||
|
||||
ENTRYPOINT ["dotnet", "cartservice.dll"]
|
||||
26
src/cartservice/src/Program.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright 2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using cartservice;
|
||||
|
||||
CreateHostBuilder(args).Build().Run();
|
||||
|
||||
static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
});
|
||||
84
src/cartservice/src/Startup.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using cartservice.cartstore;
|
||||
using cartservice.services;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
|
||||
namespace cartservice
|
||||
{
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IConfiguration configuration)
|
||||
{
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
|
||||
// This method gets called by the runtime. Use this method to add services to the container.
|
||||
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
string redisAddress = Configuration["REDIS_ADDR"];
|
||||
string spannerProjectId = Configuration["SPANNER_PROJECT"];
|
||||
string spannerConnectionString = Configuration["SPANNER_CONNECTION_STRING"];
|
||||
string alloyDBConnectionString = Configuration["ALLOYDB_PRIMARY_IP"];
|
||||
|
||||
if (!string.IsNullOrEmpty(redisAddress))
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisAddress;
|
||||
});
|
||||
services.AddSingleton<ICartStore, RedisCartStore>();
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(spannerProjectId) || !string.IsNullOrEmpty(spannerConnectionString))
|
||||
{
|
||||
services.AddSingleton<ICartStore, SpannerCartStore>();
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(alloyDBConnectionString))
|
||||
{
|
||||
Console.WriteLine("Creating AlloyDB cart store");
|
||||
services.AddSingleton<ICartStore, AlloyDBCartStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Redis cache host(hostname+port) was not specified. Starting a cart service using in memory store");
|
||||
services.AddDistributedMemoryCache();
|
||||
services.AddSingleton<ICartStore, RedisCartStore>();
|
||||
}
|
||||
|
||||
|
||||
services.AddGrpc();
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapGrpcService<CartService>();
|
||||
endpoints.MapGrpcService<cartservice.services.HealthCheckService>();
|
||||
|
||||
endpoints.MapGet("/", async context =>
|
||||
{
|
||||
await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/cartservice/src/appsettings.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
"Protocols": "Http2"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/cartservice/src/cartservice.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageReference Include="Grpc.HealthCheck" Version="2.76.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5" />
|
||||
<PackageReference Include="Google.Cloud.Spanner.Data" Version="5.12.0" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
<PackageReference Include="Google.Cloud.SecretManager.V1" Version="2.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="protos\Cart.proto" GrpcServices="Both" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
177
src/cartservice/src/cartstore/AlloyDBCartStore.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright 2021 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using Grpc.Core;
|
||||
using Npgsql;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Api.Gax.ResourceNames;
|
||||
using Google.Cloud.SecretManager.V1;
|
||||
|
||||
namespace cartservice.cartstore
|
||||
{
|
||||
public class AlloyDBCartStore : ICartStore
|
||||
{
|
||||
private readonly string tableName;
|
||||
private readonly string connectionString;
|
||||
|
||||
public AlloyDBCartStore(IConfiguration configuration)
|
||||
{
|
||||
// Create a Cloud Secrets client.
|
||||
SecretManagerServiceClient client = SecretManagerServiceClient.Create();
|
||||
var projectId = configuration["PROJECT_ID"];
|
||||
var secretId = configuration["ALLOYDB_SECRET_NAME"];
|
||||
SecretVersionName secretVersionName = new SecretVersionName(projectId, secretId, "latest");
|
||||
|
||||
AccessSecretVersionResponse result = client.AccessSecretVersion(secretVersionName);
|
||||
// Convert the payload to a string. Payloads are bytes by default.
|
||||
string alloyDBPassword = result.Payload.Data.ToStringUtf8().TrimEnd('\r', '\n');
|
||||
|
||||
// TODO: Create a separate user for connecting within the application
|
||||
// rather than using our superuser
|
||||
string alloyDBUser = "postgres";
|
||||
string databaseName = configuration["ALLOYDB_DATABASE_NAME"];
|
||||
// TODO: Consider splitting workloads into read vs. write and take
|
||||
// advantage of the AlloyDB read pools
|
||||
string primaryIPAddress = configuration["ALLOYDB_PRIMARY_IP"];
|
||||
connectionString = "Host=" +
|
||||
primaryIPAddress +
|
||||
";Username=" +
|
||||
alloyDBUser +
|
||||
";Password=" +
|
||||
alloyDBPassword +
|
||||
";Database=" +
|
||||
databaseName;
|
||||
|
||||
tableName = configuration["ALLOYDB_TABLE_NAME"];
|
||||
}
|
||||
|
||||
|
||||
public async Task AddItemAsync(string userId, string productId, int quantity)
|
||||
{
|
||||
Console.WriteLine($"AddItemAsync for {userId} called");
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
var fetchCmd = $"SELECT quantity FROM {tableName} WHERE userID='{userId}' AND productID='{productId}'";
|
||||
var currentQuantity = 0;
|
||||
var cmdRead = dataSource.CreateCommand(fetchCmd);
|
||||
await using (var reader = await cmdRead.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
currentQuantity += reader.GetInt32(0);
|
||||
}
|
||||
|
||||
var totalQuantity = quantity + currentQuantity;
|
||||
|
||||
// Use INSERT ... ON CONFLICT to prevent duplicate key error
|
||||
var insertCmd = $@"
|
||||
INSERT INTO {tableName} (userId, productId, quantity)
|
||||
VALUES ('{userId}', '{productId}', {totalQuantity})
|
||||
ON CONFLICT (userId, productId)
|
||||
DO UPDATE SET quantity = {totalQuantity};
|
||||
";
|
||||
|
||||
await using (var cmdInsert = dataSource.CreateCommand(insertCmd))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
return cmdInsert.ExecuteNonQueryAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(
|
||||
new Status(StatusCode.FailedPrecondition, $"Unable to access cart storage due to an internal error. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<Hipstershop.Cart> GetCartAsync(string userId)
|
||||
{
|
||||
Console.WriteLine($"GetCartAsync called for userId={userId}");
|
||||
Hipstershop.Cart cart = new();
|
||||
cart.UserId = userId;
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
var cartFetchCmd = $"SELECT productId, quantity FROM {tableName} WHERE userId = '{userId}'";
|
||||
var cmd = dataSource.CreateCommand(cartFetchCmd);
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
Hipstershop.CartItem item = new()
|
||||
{
|
||||
ProductId = reader.GetString(0),
|
||||
Quantity = reader.GetInt32(1)
|
||||
};
|
||||
cart.Items.Add(item);
|
||||
}
|
||||
}
|
||||
await Task.Run(() =>
|
||||
{
|
||||
return cart;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(
|
||||
new Status(StatusCode.FailedPrecondition, $"Unable to access cart storage due to an internal error. {ex}"));
|
||||
}
|
||||
return cart;
|
||||
}
|
||||
|
||||
|
||||
public async Task EmptyCartAsync(string userId)
|
||||
{
|
||||
Console.WriteLine($"EmptyCartAsync called for userId={userId}");
|
||||
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var deleteCmd = $"DELETE FROM {tableName} WHERE userID = '{userId}'";
|
||||
await using (var cmd = dataSource.CreateCommand(deleteCmd))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
return cmd.ExecuteNonQueryAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(
|
||||
new Status(StatusCode.FailedPrecondition, $"Unable to access cart storage due to an internal error. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
public bool Ping()
|
||||
{
|
||||
try
|
||||
{
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
src/cartservice/src/cartstore/ICartStore.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace cartservice.cartstore
|
||||
{
|
||||
public interface ICartStore
|
||||
{
|
||||
Task AddItemAsync(string userId, string productId, int quantity);
|
||||
Task EmptyCartAsync(string userId);
|
||||
Task<Hipstershop.Cart> GetCartAsync(string userId);
|
||||
bool Ping();
|
||||
}
|
||||
}
|
||||
118
src/cartservice/src/cartstore/RedisCartStore.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace cartservice.cartstore
|
||||
{
|
||||
public class RedisCartStore : ICartStore
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
|
||||
public RedisCartStore(IDistributedCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task AddItemAsync(string userId, string productId, int quantity)
|
||||
{
|
||||
Console.WriteLine($"AddItemAsync called with userId={userId}, productId={productId}, quantity={quantity}");
|
||||
|
||||
try
|
||||
{
|
||||
Hipstershop.Cart cart;
|
||||
var value = await _cache.GetAsync(userId);
|
||||
if (value == null)
|
||||
{
|
||||
cart = new Hipstershop.Cart();
|
||||
cart.UserId = userId;
|
||||
cart.Items.Add(new Hipstershop.CartItem { ProductId = productId, Quantity = quantity });
|
||||
}
|
||||
else
|
||||
{
|
||||
cart = Hipstershop.Cart.Parser.ParseFrom(value);
|
||||
var existingItem = cart.Items.SingleOrDefault(i => i.ProductId == productId);
|
||||
if (existingItem == null)
|
||||
{
|
||||
cart.Items.Add(new Hipstershop.CartItem { ProductId = productId, Quantity = quantity });
|
||||
}
|
||||
else
|
||||
{
|
||||
existingItem.Quantity += quantity;
|
||||
}
|
||||
}
|
||||
await _cache.SetAsync(userId, cart.ToByteArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Can't access cart storage. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EmptyCartAsync(string userId)
|
||||
{
|
||||
Console.WriteLine($"EmptyCartAsync called with userId={userId}");
|
||||
|
||||
try
|
||||
{
|
||||
var cart = new Hipstershop.Cart();
|
||||
await _cache.SetAsync(userId, cart.ToByteArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Can't access cart storage. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Hipstershop.Cart> GetCartAsync(string userId)
|
||||
{
|
||||
Console.WriteLine($"GetCartAsync called with userId={userId}");
|
||||
|
||||
try
|
||||
{
|
||||
// Access the cart from the cache
|
||||
var value = await _cache.GetAsync(userId);
|
||||
|
||||
if (value != null)
|
||||
{
|
||||
return Hipstershop.Cart.Parser.ParseFrom(value);
|
||||
}
|
||||
|
||||
// We decided to return empty cart in cases when user wasn't in the cache before
|
||||
return new Hipstershop.Cart();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Can't access cart storage. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
public bool Ping()
|
||||
{
|
||||
try
|
||||
{
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
185
src/cartservice/src/cartstore/SpannerCartStore.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
// Copyright 2021 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using Google.Cloud.Spanner.Data;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace cartservice.cartstore
|
||||
{
|
||||
public class SpannerCartStore : ICartStore
|
||||
{
|
||||
private static readonly string TableName = "CartItems";
|
||||
private static readonly string DefaultInstanceName = "onlineboutique";
|
||||
private static readonly string DefaultDatabaseName = "carts";
|
||||
private readonly string databaseString;
|
||||
|
||||
public SpannerCartStore(IConfiguration configuration)
|
||||
{
|
||||
string spannerProjectId = configuration["SPANNER_PROJECT"];
|
||||
string spannerInstanceId = configuration["SPANNER_INSTANCE"];
|
||||
string spannerDatabaseId = configuration["SPANNER_DATABASE"];
|
||||
string spannerConnectionString = configuration["SPANNER_CONNECTION_STRING"];
|
||||
SpannerConnectionStringBuilder builder = new();
|
||||
if (!string.IsNullOrEmpty(spannerConnectionString)) {
|
||||
builder.DataSource = spannerConnectionString;
|
||||
databaseString = builder.ToString();
|
||||
Console.WriteLine($"Spanner connection string: ${databaseString}");
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(spannerInstanceId))
|
||||
spannerInstanceId = DefaultInstanceName;
|
||||
if (string.IsNullOrEmpty(spannerDatabaseId))
|
||||
spannerDatabaseId = DefaultDatabaseName;
|
||||
builder.DataSource =
|
||||
$"projects/{spannerProjectId}/instances/{spannerInstanceId}/databases/{spannerDatabaseId}";
|
||||
databaseString = builder.ToString();
|
||||
Console.WriteLine($"Built Spanner connection string: '{databaseString}'");
|
||||
}
|
||||
|
||||
|
||||
public async Task AddItemAsync(string userId, string productId, int quantity)
|
||||
{
|
||||
Console.WriteLine($"AddItemAsync for {userId} called");
|
||||
try
|
||||
{
|
||||
using SpannerConnection spannerConnection = new(databaseString);
|
||||
await spannerConnection.RunWithRetriableTransactionAsync(async transaction =>
|
||||
{
|
||||
int currentQuantity = 0;
|
||||
var quantityLookup = spannerConnection.CreateSelectCommand(
|
||||
$"SELECT * FROM {TableName} WHERE userId = @userId AND productId = @productId",
|
||||
new SpannerParameterCollection
|
||||
{
|
||||
{ "userId", SpannerDbType.String },
|
||||
{ "productId", SpannerDbType.String }
|
||||
});
|
||||
quantityLookup.Parameters["userId"].Value = userId;
|
||||
quantityLookup.Parameters["productId"].Value = productId;
|
||||
quantityLookup.Transaction = transaction;
|
||||
using (var reader = await quantityLookup.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync()) {
|
||||
currentQuantity += reader.GetFieldValue<int>("quantity");
|
||||
}
|
||||
}
|
||||
|
||||
var cmd = spannerConnection.CreateInsertOrUpdateCommand(TableName,
|
||||
new SpannerParameterCollection
|
||||
{
|
||||
{ "userId", SpannerDbType.String },
|
||||
{ "productId", SpannerDbType.String },
|
||||
{ "quantity", SpannerDbType.Int64 }
|
||||
});
|
||||
cmd.Parameters["userId"].Value = userId;
|
||||
cmd.Parameters["productId"].Value = productId;
|
||||
cmd.Parameters["quantity"].Value = currentQuantity + quantity;
|
||||
cmd.Transaction = transaction;
|
||||
await Task.Run(() =>
|
||||
{
|
||||
return cmd.ExecuteNonQueryAsync();
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(
|
||||
new Status(StatusCode.FailedPrecondition, $"Can't access cart storage at {databaseString}. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<Hipstershop.Cart> GetCartAsync(string userId)
|
||||
{
|
||||
Console.WriteLine($"GetCartAsync called for userId={userId}");
|
||||
Hipstershop.Cart cart = new();
|
||||
try
|
||||
{
|
||||
using SpannerConnection spannerConnection = new(databaseString);
|
||||
var cmd = spannerConnection.CreateSelectCommand(
|
||||
$"SELECT * FROM {TableName} WHERE userId = @userId",
|
||||
new SpannerParameterCollection {
|
||||
{ "userId", SpannerDbType.String }
|
||||
}
|
||||
);
|
||||
cmd.Parameters["userId"].Value = userId;
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
// Only add the userId if something is in the cart.
|
||||
// This is based on how the cartservice example behaves.
|
||||
// An empty cart has no userId attached.
|
||||
cart.UserId = userId;
|
||||
|
||||
Hipstershop.CartItem item = new()
|
||||
{
|
||||
ProductId = reader.GetFieldValue<string>("productId"),
|
||||
Quantity = reader.GetFieldValue<int>("quantity")
|
||||
};
|
||||
cart.Items.Add(item);
|
||||
}
|
||||
|
||||
return cart;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(
|
||||
new Status(StatusCode.FailedPrecondition, $"Can't access cart storage at {databaseString}. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task EmptyCartAsync(string userId)
|
||||
{
|
||||
Console.WriteLine($"EmptyCartAsync called for userId={userId}");
|
||||
|
||||
try
|
||||
{
|
||||
using SpannerConnection spannerConnection = new(databaseString);
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var cmd = spannerConnection.CreateDmlCommand(
|
||||
$"DELETE FROM {TableName} WHERE userId = @userId",
|
||||
new SpannerParameterCollection
|
||||
{
|
||||
{ "userId", SpannerDbType.String }
|
||||
});
|
||||
cmd.Parameters["userId"].Value = userId;
|
||||
return cmd.ExecuteNonQueryAsync();
|
||||
});
|
||||
}
|
||||
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new RpcException(
|
||||
new Status(StatusCode.FailedPrecondition, $"Can't access cart storage at {databaseString}. {ex}"));
|
||||
}
|
||||
}
|
||||
|
||||
public bool Ping()
|
||||
{
|
||||
try
|
||||
{
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50
src/cartservice/src/protos/Cart.proto
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package hipstershop;
|
||||
|
||||
// -----------------Cart service-----------------
|
||||
|
||||
service CartService {
|
||||
rpc AddItem(AddItemRequest) returns (Empty) {}
|
||||
rpc GetCart(GetCartRequest) returns (Cart) {}
|
||||
rpc EmptyCart(EmptyCartRequest) returns (Empty) {}
|
||||
}
|
||||
|
||||
message CartItem {
|
||||
string product_id = 1;
|
||||
int32 quantity = 2;
|
||||
}
|
||||
|
||||
message AddItemRequest {
|
||||
string user_id = 1;
|
||||
CartItem item = 2;
|
||||
}
|
||||
|
||||
message EmptyCartRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message GetCartRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message Cart {
|
||||
string user_id = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
51
src/cartservice/src/services/CartService.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright 2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using cartservice.cartstore;
|
||||
using Hipstershop;
|
||||
|
||||
namespace cartservice.services
|
||||
{
|
||||
public class CartService : Hipstershop.CartService.CartServiceBase
|
||||
{
|
||||
private readonly static Empty Empty = new Empty();
|
||||
private readonly ICartStore _cartStore;
|
||||
|
||||
public CartService(ICartStore cartStore)
|
||||
{
|
||||
_cartStore = cartStore;
|
||||
}
|
||||
|
||||
public async override Task<Empty> AddItem(AddItemRequest request, ServerCallContext context)
|
||||
{
|
||||
await _cartStore.AddItemAsync(request.UserId, request.Item.ProductId, request.Item.Quantity);
|
||||
return Empty;
|
||||
}
|
||||
|
||||
public override Task<Cart> GetCart(GetCartRequest request, ServerCallContext context)
|
||||
{
|
||||
return _cartStore.GetCartAsync(request.UserId);
|
||||
}
|
||||
|
||||
public async override Task<Empty> EmptyCart(EmptyCartRequest request, ServerCallContext context)
|
||||
{
|
||||
await _cartStore.EmptyCartAsync(request.UserId);
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/cartservice/src/services/HealthCheckService.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using static Grpc.Health.V1.Health;
|
||||
using cartservice.cartstore;
|
||||
|
||||
namespace cartservice.services
|
||||
{
|
||||
internal class HealthCheckService : HealthBase
|
||||
{
|
||||
private ICartStore _cartStore { get; }
|
||||
|
||||
public HealthCheckService (ICartStore cartStore)
|
||||
{
|
||||
_cartStore = cartStore;
|
||||
}
|
||||
|
||||
public override Task<HealthCheckResponse> Check(HealthCheckRequest request, ServerCallContext context)
|
||||
{
|
||||
Console.WriteLine ("Checking CartService Health");
|
||||
return Task.FromResult(new HealthCheckResponse {
|
||||
Status = _cartStore.Ping() ? HealthCheckResponse.Types.ServingStatus.Serving : HealthCheckResponse.Types.ServingStatus.NotServing
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/cartservice/tests/CartServiceTests.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Net.Client;
|
||||
using Hipstershop;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
using static Hipstershop.CartService;
|
||||
|
||||
namespace cartservice.tests
|
||||
{
|
||||
public class CartServiceTests
|
||||
{
|
||||
private readonly IHostBuilder _host;
|
||||
|
||||
public CartServiceTests()
|
||||
{
|
||||
_host = new HostBuilder().ConfigureWebHost(webBuilder =>
|
||||
{
|
||||
webBuilder
|
||||
.UseStartup<Startup>()
|
||||
.UseTestServer();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetItem_NoAddItemBefore_EmptyCartReturned()
|
||||
{
|
||||
// Setup test server and client
|
||||
using var server = await _host.StartAsync();
|
||||
var httpClient = server.GetTestClient();
|
||||
|
||||
string userId = Guid.NewGuid().ToString();
|
||||
|
||||
// Create a GRPC communication channel between the client and the server
|
||||
var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions
|
||||
{
|
||||
HttpClient = httpClient
|
||||
});
|
||||
|
||||
var cartClient = new CartServiceClient(channel);
|
||||
|
||||
var request = new GetCartRequest
|
||||
{
|
||||
UserId = userId,
|
||||
};
|
||||
|
||||
var cart = await cartClient.GetCartAsync(request);
|
||||
Assert.NotNull(cart);
|
||||
|
||||
// All grpc objects implement IEquitable, so we can compare equality with by-value semantics
|
||||
Assert.Equal(new Cart(), cart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddItem_ItemExists_Updated()
|
||||
{
|
||||
// Setup test server and client
|
||||
using var server = await _host.StartAsync();
|
||||
var httpClient = server.GetTestClient();
|
||||
|
||||
string userId = Guid.NewGuid().ToString();
|
||||
|
||||
// Create a GRPC communication channel between the client and the server
|
||||
var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions
|
||||
{
|
||||
HttpClient = httpClient
|
||||
});
|
||||
|
||||
var client = new CartServiceClient(channel);
|
||||
var request = new AddItemRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Item = new CartItem
|
||||
{
|
||||
ProductId = "1",
|
||||
Quantity = 1
|
||||
}
|
||||
};
|
||||
|
||||
// First add - nothing should fail
|
||||
await client.AddItemAsync(request);
|
||||
|
||||
// Second add of existing product - quantity should be updated
|
||||
await client.AddItemAsync(request);
|
||||
|
||||
var getCartRequest = new GetCartRequest
|
||||
{
|
||||
UserId = userId
|
||||
};
|
||||
var cart = await client.GetCartAsync(getCartRequest);
|
||||
Assert.NotNull(cart);
|
||||
Assert.Equal(userId, cart.UserId);
|
||||
Assert.Single(cart.Items);
|
||||
Assert.Equal(2, cart.Items[0].Quantity);
|
||||
|
||||
// Cleanup
|
||||
await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddItem_New_Inserted()
|
||||
{
|
||||
// Setup test server and client
|
||||
using var server = await _host.StartAsync();
|
||||
var httpClient = server.GetTestClient();
|
||||
|
||||
string userId = Guid.NewGuid().ToString();
|
||||
|
||||
// Create a GRPC communication channel between the client and the server
|
||||
var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions
|
||||
{
|
||||
HttpClient = httpClient
|
||||
});
|
||||
|
||||
// Create a proxy object to work with the server
|
||||
var client = new CartServiceClient(channel);
|
||||
|
||||
var request = new AddItemRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Item = new CartItem
|
||||
{
|
||||
ProductId = "1",
|
||||
Quantity = 1
|
||||
}
|
||||
};
|
||||
|
||||
await client.AddItemAsync(request);
|
||||
|
||||
var getCartRequest = new GetCartRequest
|
||||
{
|
||||
UserId = userId
|
||||
};
|
||||
var cart = await client.GetCartAsync(getCartRequest);
|
||||
Assert.NotNull(cart);
|
||||
Assert.Equal(userId, cart.UserId);
|
||||
Assert.Single(cart.Items);
|
||||
|
||||
await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId });
|
||||
cart = await client.GetCartAsync(getCartRequest);
|
||||
Assert.Empty(cart.Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/cartservice/tests/cartservice.tests.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\src\cartservice.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
1
src/checkoutservice/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
vendor/
|
||||
44
src/checkoutservice/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Copyright 2020 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Define a default value so it's not empty if the builder fails to provide it
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
WORKDIR /src
|
||||
|
||||
# restore dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Skaffold passes in debug-oriented compiler flags
|
||||
ARG SKAFFOLD_GO_GCFLAGS
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -ldflags="-s -w" -gcflags="${SKAFFOLD_GO_GCFLAGS}" -o /checkoutservice .
|
||||
|
||||
FROM gcr.io/distroless/static
|
||||
|
||||
WORKDIR /src
|
||||
COPY --from=builder /checkoutservice /src/checkoutservice
|
||||
|
||||
# Definition of this variable is used by 'skaffold debug' to identify a golang binary.
|
||||
# Default behavior - a failure prints a stack trace for the current goroutine.
|
||||
# See https://golang.org/pkg/runtime/
|
||||
ENV GOTRACEBACK=single
|
||||
|
||||
EXPOSE 5050
|
||||
ENTRYPOINT ["/src/checkoutservice"]
|
||||
5
src/checkoutservice/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# checkoutservice
|
||||
|
||||
Run the following command to restore dependencies to `vendor/` directory:
|
||||
|
||||
dep ensure --vendor-only
|
||||
25
src/checkoutservice/genproto.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash -eu
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# [START gke_checkoutservice_genproto]
|
||||
|
||||
PATH=$PATH:$(go env GOPATH)/bin
|
||||
protodir=../../protos
|
||||
outdir=./genproto
|
||||
|
||||
protoc --proto_path=$protodir --go_out=./$outdir --go_opt=paths=source_relative --go-grpc_out=./$outdir --go-grpc_opt=paths=source_relative $protodir/demo.proto
|
||||
|
||||
# [END gke_checkoutservice_genproto]
|
||||
2610
src/checkoutservice/genproto/demo.pb.go
Normal file
1179
src/checkoutservice/genproto/demo_grpc.pb.go
Normal file
50
src/checkoutservice/go.mod
Normal file
@@ -0,0 +1,50 @@
|
||||
module github.com/GoogleCloudPlatform/microservices-demo/src/checkoutservice
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.1
|
||||
|
||||
require (
|
||||
cloud.google.com/go/profiler v0.4.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.256.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
)
|
||||
132
src/checkoutservice/go.sum
Normal file
@@ -0,0 +1,132 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/profiler v0.4.3 h1:IY3QNKlr8VbXwGWHcZbJQsMA/83ZTH6uAHf8jYyj7OI=
|
||||
cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0=
|
||||
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
||||
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846 h1:dDbsTLIK7EzwUq36kCSAsk0slouq/S0tWHeeGi97cD8=
|
||||
google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846/go.mod h1:PP0g88Dz3C7hRAfbQCQggeWAXjuqGsNPLE4s7jh0RGU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
394
src/checkoutservice/main.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/profiler"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/checkoutservice/genproto"
|
||||
money "github.com/GoogleCloudPlatform/microservices-demo/src/checkoutservice/money"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
listenPort = "5050"
|
||||
usdCurrency = "USD"
|
||||
)
|
||||
|
||||
var log *logrus.Logger
|
||||
|
||||
func init() {
|
||||
log = logrus.New()
|
||||
log.Level = logrus.DebugLevel
|
||||
log.Formatter = &logrus.JSONFormatter{
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyTime: "timestamp",
|
||||
logrus.FieldKeyLevel: "severity",
|
||||
logrus.FieldKeyMsg: "message",
|
||||
},
|
||||
TimestampFormat: time.RFC3339Nano,
|
||||
}
|
||||
log.Out = os.Stdout
|
||||
}
|
||||
|
||||
type checkoutService struct {
|
||||
pb.UnimplementedCheckoutServiceServer
|
||||
|
||||
productCatalogSvcAddr string
|
||||
productCatalogSvcConn *grpc.ClientConn
|
||||
|
||||
cartSvcAddr string
|
||||
cartSvcConn *grpc.ClientConn
|
||||
|
||||
currencySvcAddr string
|
||||
currencySvcConn *grpc.ClientConn
|
||||
|
||||
shippingSvcAddr string
|
||||
shippingSvcConn *grpc.ClientConn
|
||||
|
||||
emailSvcAddr string
|
||||
emailSvcConn *grpc.ClientConn
|
||||
|
||||
paymentSvcAddr string
|
||||
paymentSvcConn *grpc.ClientConn
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
if os.Getenv("ENABLE_TRACING") == "1" {
|
||||
log.Info("Tracing enabled.")
|
||||
initTracing()
|
||||
|
||||
} else {
|
||||
log.Info("Tracing disabled.")
|
||||
}
|
||||
|
||||
if os.Getenv("ENABLE_PROFILER") == "1" {
|
||||
log.Info("Profiling enabled.")
|
||||
go initProfiling("checkoutservice", "1.0.0")
|
||||
} else {
|
||||
log.Info("Profiling disabled.")
|
||||
}
|
||||
|
||||
port := listenPort
|
||||
if os.Getenv("PORT") != "" {
|
||||
port = os.Getenv("PORT")
|
||||
}
|
||||
|
||||
svc := new(checkoutService)
|
||||
mustMapEnv(&svc.shippingSvcAddr, "SHIPPING_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.productCatalogSvcAddr, "PRODUCT_CATALOG_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.cartSvcAddr, "CART_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.currencySvcAddr, "CURRENCY_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.emailSvcAddr, "EMAIL_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.paymentSvcAddr, "PAYMENT_SERVICE_ADDR")
|
||||
|
||||
mustConnGRPC(ctx, &svc.shippingSvcConn, svc.shippingSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.productCatalogSvcConn, svc.productCatalogSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.cartSvcConn, svc.cartSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.currencySvcConn, svc.currencySvcAddr)
|
||||
mustConnGRPC(ctx, &svc.emailSvcConn, svc.emailSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.paymentSvcConn, svc.paymentSvcAddr)
|
||||
|
||||
log.Infof("service config: %+v", svc)
|
||||
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var srv *grpc.Server
|
||||
|
||||
// Propagate trace context always
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{}, propagation.Baggage{}))
|
||||
srv = grpc.NewServer(
|
||||
grpc.StatsHandler(otelgrpc.NewServerHandler()),
|
||||
)
|
||||
|
||||
pb.RegisterCheckoutServiceServer(srv, svc)
|
||||
healthcheck := health.NewServer()
|
||||
healthpb.RegisterHealthServer(srv, healthcheck)
|
||||
log.Infof("starting to listen on tcp: %q", lis.Addr().String())
|
||||
err = srv.Serve(lis)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
func initStats() {
|
||||
//TODO(arbrown) Implement OpenTelemetry stats
|
||||
}
|
||||
|
||||
func initTracing() {
|
||||
var (
|
||||
collectorAddr string
|
||||
collectorConn *grpc.ClientConn
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
|
||||
defer cancel()
|
||||
|
||||
mustMapEnv(&collectorAddr, "COLLECTOR_SERVICE_ADDR")
|
||||
mustConnGRPC(ctx, &collectorConn, collectorAddr)
|
||||
|
||||
exporter, err := otlptracegrpc.New(
|
||||
ctx,
|
||||
otlptracegrpc.WithGRPCConn(collectorConn))
|
||||
if err != nil {
|
||||
log.Warnf("warn: Failed to create trace exporter: %v", err)
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()))
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
}
|
||||
|
||||
func initProfiling(service, version string) {
|
||||
// TODO(ahmetb) this method is duplicated in other microservices using Go
|
||||
// since they are not sharing packages.
|
||||
for i := 1; i <= 3; i++ {
|
||||
if err := profiler.Start(profiler.Config{
|
||||
Service: service,
|
||||
ServiceVersion: version,
|
||||
// ProjectID must be set if not running on GCP.
|
||||
// ProjectID: "my-project",
|
||||
}); err != nil {
|
||||
log.Warnf("failed to start profiler: %+v", err)
|
||||
} else {
|
||||
log.Info("started Stackdriver profiler")
|
||||
return
|
||||
}
|
||||
d := time.Second * 10 * time.Duration(i)
|
||||
log.Infof("sleeping %v to retry initializing Stackdriver profiler", d)
|
||||
time.Sleep(d)
|
||||
}
|
||||
log.Warn("could not initialize Stackdriver profiler after retrying, giving up")
|
||||
}
|
||||
|
||||
func mustMapEnv(target *string, envKey string) {
|
||||
v := os.Getenv(envKey)
|
||||
if v == "" {
|
||||
panic(fmt.Sprintf("environment variable %q not set", envKey))
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
|
||||
func mustConnGRPC(ctx context.Context, conn **grpc.ClientConn, addr string) {
|
||||
var err error
|
||||
_, cancel := context.WithTimeout(ctx, time.Second*3)
|
||||
defer cancel()
|
||||
*conn, err = grpc.NewClient(addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
|
||||
if err != nil {
|
||||
panic(errors.Wrapf(err, "grpc: failed to connect %s", addr))
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *checkoutService) Check(ctx context.Context, req *healthpb.HealthCheckRequest) (*healthpb.HealthCheckResponse, error) {
|
||||
return &healthpb.HealthCheckResponse{Status: healthpb.HealthCheckResponse_SERVING}, nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) Watch(req *healthpb.HealthCheckRequest, ws healthpb.Health_WatchServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "health check via Watch not implemented")
|
||||
}
|
||||
|
||||
func (cs *checkoutService) PlaceOrder(ctx context.Context, req *pb.PlaceOrderRequest) (*pb.PlaceOrderResponse, error) {
|
||||
log.Infof("[PlaceOrder] user_id=%q user_currency=%q", req.UserId, req.UserCurrency)
|
||||
|
||||
orderID, err := uuid.NewUUID()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to generate order uuid")
|
||||
}
|
||||
|
||||
prep, err := cs.prepareOrderItemsAndShippingQuoteFromCart(ctx, req.UserId, req.UserCurrency, req.Address)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
total := pb.Money{CurrencyCode: req.UserCurrency,
|
||||
Units: 0,
|
||||
Nanos: 0}
|
||||
total = money.Must(money.Sum(total, *prep.shippingCostLocalized))
|
||||
for _, it := range prep.orderItems {
|
||||
multPrice := money.MultiplySlow(*it.Cost, uint32(it.GetItem().GetQuantity()))
|
||||
total = money.Must(money.Sum(total, multPrice))
|
||||
}
|
||||
|
||||
txID, err := cs.chargeCard(ctx, &total, req.CreditCard)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to charge card: %+v", err)
|
||||
}
|
||||
log.Infof("payment went through (transaction_id: %s)", txID)
|
||||
|
||||
shippingTrackingID, err := cs.shipOrder(ctx, req.Address, prep.cartItems)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unavailable, "shipping error: %+v", err)
|
||||
}
|
||||
|
||||
_ = cs.emptyUserCart(ctx, req.UserId)
|
||||
|
||||
orderResult := &pb.OrderResult{
|
||||
OrderId: orderID.String(),
|
||||
ShippingTrackingId: shippingTrackingID,
|
||||
ShippingCost: prep.shippingCostLocalized,
|
||||
ShippingAddress: req.Address,
|
||||
Items: prep.orderItems,
|
||||
}
|
||||
|
||||
if err := cs.sendOrderConfirmation(ctx, req.Email, orderResult); err != nil {
|
||||
log.Warnf("failed to send order confirmation to %q: %+v", req.Email, err)
|
||||
} else {
|
||||
log.Infof("order confirmation email sent to %q", req.Email)
|
||||
}
|
||||
resp := &pb.PlaceOrderResponse{Order: orderResult}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type orderPrep struct {
|
||||
orderItems []*pb.OrderItem
|
||||
cartItems []*pb.CartItem
|
||||
shippingCostLocalized *pb.Money
|
||||
}
|
||||
|
||||
func (cs *checkoutService) prepareOrderItemsAndShippingQuoteFromCart(ctx context.Context, userID, userCurrency string, address *pb.Address) (orderPrep, error) {
|
||||
var out orderPrep
|
||||
cartItems, err := cs.getUserCart(ctx, userID)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("cart failure: %+v", err)
|
||||
}
|
||||
orderItems, err := cs.prepOrderItems(ctx, cartItems, userCurrency)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("failed to prepare order: %+v", err)
|
||||
}
|
||||
shippingUSD, err := cs.quoteShipping(ctx, address, cartItems)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("shipping quote failure: %+v", err)
|
||||
}
|
||||
shippingPrice, err := cs.convertCurrency(ctx, shippingUSD, userCurrency)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("failed to convert shipping cost to currency: %+v", err)
|
||||
}
|
||||
|
||||
out.shippingCostLocalized = shippingPrice
|
||||
out.cartItems = cartItems
|
||||
out.orderItems = orderItems
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) quoteShipping(ctx context.Context, address *pb.Address, items []*pb.CartItem) (*pb.Money, error) {
|
||||
shippingQuote, err := pb.NewShippingServiceClient(cs.shippingSvcConn).
|
||||
GetQuote(ctx, &pb.GetQuoteRequest{
|
||||
Address: address,
|
||||
Items: items})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get shipping quote: %+v", err)
|
||||
}
|
||||
return shippingQuote.GetCostUsd(), nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) getUserCart(ctx context.Context, userID string) ([]*pb.CartItem, error) {
|
||||
cart, err := pb.NewCartServiceClient(cs.cartSvcConn).GetCart(ctx, &pb.GetCartRequest{UserId: userID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user cart during checkout: %+v", err)
|
||||
}
|
||||
return cart.GetItems(), nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) emptyUserCart(ctx context.Context, userID string) error {
|
||||
if _, err := pb.NewCartServiceClient(cs.cartSvcConn).EmptyCart(ctx, &pb.EmptyCartRequest{UserId: userID}); err != nil {
|
||||
return fmt.Errorf("failed to empty user cart during checkout: %+v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) prepOrderItems(ctx context.Context, items []*pb.CartItem, userCurrency string) ([]*pb.OrderItem, error) {
|
||||
out := make([]*pb.OrderItem, len(items))
|
||||
cl := pb.NewProductCatalogServiceClient(cs.productCatalogSvcConn)
|
||||
|
||||
for i, item := range items {
|
||||
product, err := cl.GetProduct(ctx, &pb.GetProductRequest{Id: item.GetProductId()})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get product #%q", item.GetProductId())
|
||||
}
|
||||
price, err := cs.convertCurrency(ctx, product.GetPriceUsd(), userCurrency)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert price of %q to %s", item.GetProductId(), userCurrency)
|
||||
}
|
||||
out[i] = &pb.OrderItem{
|
||||
Item: item,
|
||||
Cost: price}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) convertCurrency(ctx context.Context, from *pb.Money, toCurrency string) (*pb.Money, error) {
|
||||
result, err := pb.NewCurrencyServiceClient(cs.currencySvcConn).Convert(context.TODO(), &pb.CurrencyConversionRequest{
|
||||
From: from,
|
||||
ToCode: toCurrency})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert currency: %+v", err)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (cs *checkoutService) chargeCard(ctx context.Context, amount *pb.Money, paymentInfo *pb.CreditCardInfo) (string, error) {
|
||||
paymentResp, err := pb.NewPaymentServiceClient(cs.paymentSvcConn).Charge(ctx, &pb.ChargeRequest{
|
||||
Amount: amount,
|
||||
CreditCard: paymentInfo})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not charge the card: %+v", err)
|
||||
}
|
||||
return paymentResp.GetTransactionId(), nil
|
||||
}
|
||||
|
||||
func (cs *checkoutService) sendOrderConfirmation(ctx context.Context, email string, order *pb.OrderResult) error {
|
||||
_, err := pb.NewEmailServiceClient(cs.emailSvcConn).SendOrderConfirmation(ctx, &pb.SendOrderConfirmationRequest{
|
||||
Email: email,
|
||||
Order: order})
|
||||
return err
|
||||
}
|
||||
|
||||
func (cs *checkoutService) shipOrder(ctx context.Context, address *pb.Address, items []*pb.CartItem) (string, error) {
|
||||
resp, err := pb.NewShippingServiceClient(cs.shippingSvcConn).ShipOrder(ctx, &pb.ShipOrderRequest{
|
||||
Address: address,
|
||||
Items: items})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shipment failed: %+v", err)
|
||||
}
|
||||
return resp.GetTrackingId(), nil
|
||||
}
|
||||
132
src/checkoutservice/money/money.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/checkoutservice/genproto"
|
||||
)
|
||||
|
||||
const (
|
||||
nanosMin = -999999999
|
||||
nanosMax = +999999999
|
||||
nanosMod = 1000000000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidValue = errors.New("one of the specified money values is invalid")
|
||||
ErrMismatchingCurrency = errors.New("mismatching currency codes")
|
||||
)
|
||||
|
||||
// IsValid checks if specified value has a valid units/nanos signs and ranges.
|
||||
func IsValid(m pb.Money) bool {
|
||||
return signMatches(m) && validNanos(m.GetNanos())
|
||||
}
|
||||
|
||||
func signMatches(m pb.Money) bool {
|
||||
return m.GetNanos() == 0 || m.GetUnits() == 0 || (m.GetNanos() < 0) == (m.GetUnits() < 0)
|
||||
}
|
||||
|
||||
func validNanos(nanos int32) bool { return nanosMin <= nanos && nanos <= nanosMax }
|
||||
|
||||
// IsZero returns true if the specified money value is equal to zero.
|
||||
func IsZero(m pb.Money) bool { return m.GetUnits() == 0 && m.GetNanos() == 0 }
|
||||
|
||||
// IsPositive returns true if the specified money value is valid and is
|
||||
// positive.
|
||||
func IsPositive(m pb.Money) bool {
|
||||
return IsValid(m) && m.GetUnits() > 0 || (m.GetUnits() == 0 && m.GetNanos() > 0)
|
||||
}
|
||||
|
||||
// IsNegative returns true if the specified money value is valid and is
|
||||
// negative.
|
||||
func IsNegative(m pb.Money) bool {
|
||||
return IsValid(m) && m.GetUnits() < 0 || (m.GetUnits() == 0 && m.GetNanos() < 0)
|
||||
}
|
||||
|
||||
// AreSameCurrency returns true if values l and r have a currency code and
|
||||
// they are the same values.
|
||||
func AreSameCurrency(l, r pb.Money) bool {
|
||||
return l.GetCurrencyCode() == r.GetCurrencyCode() && l.GetCurrencyCode() != ""
|
||||
}
|
||||
|
||||
// AreEquals returns true if values l and r are the equal, including the
|
||||
// currency. This does not check validity of the provided values.
|
||||
func AreEquals(l, r pb.Money) bool {
|
||||
return l.GetCurrencyCode() == r.GetCurrencyCode() &&
|
||||
l.GetUnits() == r.GetUnits() && l.GetNanos() == r.GetNanos()
|
||||
}
|
||||
|
||||
// Negate returns the same amount with the sign negated.
|
||||
func Negate(m pb.Money) pb.Money {
|
||||
return pb.Money{
|
||||
Units: -m.GetUnits(),
|
||||
Nanos: -m.GetNanos(),
|
||||
CurrencyCode: m.GetCurrencyCode()}
|
||||
}
|
||||
|
||||
// Must panics if the given error is not nil. This can be used with other
|
||||
// functions like: "m := Must(Sum(a,b))".
|
||||
func Must(v pb.Money, err error) pb.Money {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Sum adds two values. Returns an error if one of the values are invalid or
|
||||
// currency codes are not matching (unless currency code is unspecified for
|
||||
// both).
|
||||
func Sum(l, r pb.Money) (pb.Money, error) {
|
||||
if !IsValid(l) || !IsValid(r) {
|
||||
return pb.Money{}, ErrInvalidValue
|
||||
} else if l.GetCurrencyCode() != r.GetCurrencyCode() {
|
||||
return pb.Money{}, ErrMismatchingCurrency
|
||||
}
|
||||
units := l.GetUnits() + r.GetUnits()
|
||||
nanos := l.GetNanos() + r.GetNanos()
|
||||
|
||||
if (units == 0 && nanos == 0) || (units > 0 && nanos >= 0) || (units < 0 && nanos <= 0) {
|
||||
// same sign <units, nanos>
|
||||
units += int64(nanos / nanosMod)
|
||||
nanos = nanos % nanosMod
|
||||
} else {
|
||||
// different sign. nanos guaranteed to not to go over the limit
|
||||
if units > 0 {
|
||||
units--
|
||||
nanos += nanosMod
|
||||
} else {
|
||||
units++
|
||||
nanos -= nanosMod
|
||||
}
|
||||
}
|
||||
|
||||
return pb.Money{
|
||||
Units: units,
|
||||
Nanos: nanos,
|
||||
CurrencyCode: l.GetCurrencyCode()}, nil
|
||||
}
|
||||
|
||||
// MultiplySlow is a slow multiplication operation done through adding the value
|
||||
// to itself n-1 times.
|
||||
func MultiplySlow(m pb.Money, n uint32) pb.Money {
|
||||
out := m
|
||||
for n > 1 {
|
||||
out = Must(Sum(out, m))
|
||||
n--
|
||||
}
|
||||
return out
|
||||
}
|
||||
245
src/checkoutservice/money/money_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/checkoutservice/genproto"
|
||||
)
|
||||
|
||||
func mmc(u int64, n int32, c string) pb.Money { return pb.Money{Units: u, Nanos: n, CurrencyCode: c} }
|
||||
func mm(u int64, n int32) pb.Money { return mmc(u, n, "") }
|
||||
|
||||
func TestIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"valid -/-", mm(-981273891273, -999999999), true},
|
||||
{"invalid -/+", mm(-981273891273, +999999999), false},
|
||||
{"valid +/+", mm(981273891273, 999999999), true},
|
||||
{"invalid +/-", mm(981273891273, -999999999), false},
|
||||
{"invalid +/+overflow", mm(3, 1000000000), false},
|
||||
{"invalid +/-overflow", mm(3, -1000000000), false},
|
||||
{"invalid -/+overflow", mm(-3, 1000000000), false},
|
||||
{"invalid -/-overflow", mm(-3, -1000000000), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValid(tt.in); got != tt.want {
|
||||
t.Errorf("IsValid(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZero(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"zero", mm(0, 0), true},
|
||||
{"not-zero (-/+)", mm(-1, +1), false},
|
||||
{"not-zero (-/-)", mm(-1, -1), false},
|
||||
{"not-zero (+/+)", mm(+1, +1), false},
|
||||
{"not-zero (+/-)", mm(+1, -1), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsZero(tt.in); got != tt.want {
|
||||
t.Errorf("IsZero(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPositive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"zero", mm(0, 0), false},
|
||||
{"positive (+/+)", mm(+1, +1), true},
|
||||
{"invalid (-/+)", mm(-1, +1), false},
|
||||
{"negative (-/-)", mm(-1, -1), false},
|
||||
{"invalid (+/-)", mm(+1, -1), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsPositive(tt.in); got != tt.want {
|
||||
t.Errorf("IsPositive(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNegative(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"zero", mm(0, 0), false},
|
||||
{"positive (+/+)", mm(+1, +1), false},
|
||||
{"invalid (-/+)", mm(-1, +1), false},
|
||||
{"negative (-/-)", mm(-1, -1), true},
|
||||
{"invalid (+/-)", mm(+1, -1), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsNegative(tt.in); got != tt.want {
|
||||
t.Errorf("IsNegative(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreSameCurrency(t *testing.T) {
|
||||
type args struct {
|
||||
l pb.Money
|
||||
r pb.Money
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{"both empty currency", args{mmc(1, 0, ""), mmc(2, 0, "")}, false},
|
||||
{"left empty currency", args{mmc(1, 0, ""), mmc(2, 0, "USD")}, false},
|
||||
{"right empty currency", args{mmc(1, 0, "USD"), mmc(2, 0, "")}, false},
|
||||
{"mismatching", args{mmc(1, 0, "USD"), mmc(2, 0, "CAD")}, false},
|
||||
{"matching", args{mmc(1, 0, "USD"), mmc(2, 0, "USD")}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AreSameCurrency(tt.args.l, tt.args.r); got != tt.want {
|
||||
t.Errorf("AreSameCurrency([%v],[%v]) = %v, want %v", tt.args.l, tt.args.r, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreEquals(t *testing.T) {
|
||||
type args struct {
|
||||
l pb.Money
|
||||
r pb.Money
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{"equals", args{mmc(1, 2, "USD"), mmc(1, 2, "USD")}, true},
|
||||
{"mismatching currency", args{mmc(1, 2, "USD"), mmc(1, 2, "CAD")}, false},
|
||||
{"mismatching units", args{mmc(10, 20, "USD"), mmc(1, 20, "USD")}, false},
|
||||
{"mismatching nanos", args{mmc(1, 2, "USD"), mmc(1, 20, "USD")}, false},
|
||||
{"negated", args{mmc(1, 2, "USD"), mmc(-1, -2, "USD")}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AreEquals(tt.args.l, tt.args.r); got != tt.want {
|
||||
t.Errorf("AreEquals([%v],[%v]) = %v, want %v", tt.args.l, tt.args.r, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want pb.Money
|
||||
}{
|
||||
{"zero", mm(0, 0), mm(0, 0)},
|
||||
{"negative", mm(-1, -200), mm(1, 200)},
|
||||
{"positive", mm(1, 200), mm(-1, -200)},
|
||||
{"carries currency code", mmc(0, 0, "XXX"), mmc(0, 0, "XXX")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := Negate(tt.in); !AreEquals(got, tt.want) {
|
||||
t.Errorf("Negate([%v]) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMust_pass(t *testing.T) {
|
||||
v := Must(mm(2, 3), nil)
|
||||
if !AreEquals(v, mm(2, 3)) {
|
||||
t.Errorf("returned the wrong value: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMust_panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf("panic captured: %v", r)
|
||||
}
|
||||
}()
|
||||
Must(mm(2, 3), fmt.Errorf("some error"))
|
||||
t.Fatal("this should not have executed due to the panic above")
|
||||
}
|
||||
|
||||
func TestSum(t *testing.T) {
|
||||
type args struct {
|
||||
l pb.Money
|
||||
r pb.Money
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want pb.Money
|
||||
wantErr error
|
||||
}{
|
||||
{"0+0=0", args{mm(0, 0), mm(0, 0)}, mm(0, 0), nil},
|
||||
{"Error: currency code on left", args{mmc(0, 0, "XXX"), mm(0, 0)}, mm(0, 0), ErrMismatchingCurrency},
|
||||
{"Error: currency code on right", args{mm(0, 0), mmc(0, 0, "YYY")}, mm(0, 0), ErrMismatchingCurrency},
|
||||
{"Error: currency code mismatch", args{mmc(0, 0, "AAA"), mmc(0, 0, "BBB")}, mm(0, 0), ErrMismatchingCurrency},
|
||||
{"Error: invalid +/-", args{mm(+1, -1), mm(0, 0)}, mm(0, 0), ErrInvalidValue},
|
||||
{"Error: invalid -/+", args{mm(0, 0), mm(-1, +2)}, mm(0, 0), ErrInvalidValue},
|
||||
{"Error: invalid nanos", args{mm(0, 1000000000), mm(1, 0)}, mm(0, 0), ErrInvalidValue},
|
||||
{"both positive (no carry)", args{mm(2, 200000000), mm(2, 200000000)}, mm(4, 400000000), nil},
|
||||
{"both positive (nanos=max)", args{mm(2, 111111111), mm(2, 888888888)}, mm(4, 999999999), nil},
|
||||
{"both positive (carry)", args{mm(2, 200000000), mm(2, 900000000)}, mm(5, 100000000), nil},
|
||||
{"both negative (no carry)", args{mm(-2, -200000000), mm(-2, -200000000)}, mm(-4, -400000000), nil},
|
||||
{"both negative (carry)", args{mm(-2, -200000000), mm(-2, -900000000)}, mm(-5, -100000000), nil},
|
||||
{"mixed (larger positive, just decimals)", args{mm(11, 0), mm(-2, 0)}, mm(9, 0), nil},
|
||||
{"mixed (larger negative, just decimals)", args{mm(-11, 0), mm(2, 0)}, mm(-9, 0), nil},
|
||||
{"mixed (larger positive, no borrow)", args{mm(11, 100000000), mm(-2, -100000000)}, mm(9, 0), nil},
|
||||
{"mixed (larger positive, with borrow)", args{mm(11, 100000000), mm(-2, -9000000 /*.09*/)}, mm(9, 91000000 /*.091*/), nil},
|
||||
{"mixed (larger negative, no borrow)", args{mm(-11, -100000000), mm(2, 100000000)}, mm(-9, 0), nil},
|
||||
{"mixed (larger negative, with borrow)", args{mm(-11, -100000000), mm(2, 9000000 /*.09*/)}, mm(-9, -91000000 /*.091*/), nil},
|
||||
{"0+negative", args{mm(0, 0), mm(-2, -100000000)}, mm(-2, -100000000), nil},
|
||||
{"negative+0", args{mm(-2, -100000000), mm(0, 0)}, mm(-2, -100000000), nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Sum(tt.args.l, tt.args.r)
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("Sum([%v],[%v]): expected err=\"%v\" got=\"%v\"", tt.args.l, tt.args.r, tt.wantErr, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Sum([%v],[%v]) = %v, want %v", tt.args.l, tt.args.r, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
18
src/checkstyle/nohttp-checkstyle-suppressions.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE suppressions PUBLIC
|
||||
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
|
||||
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
|
||||
<suppressions>
|
||||
<!-- Upstream defaults -->
|
||||
<suppress files="node_modules[\\/].*" checks=".*"/>
|
||||
<suppress files="node[\\/].*" checks=".*"/>
|
||||
<suppress files="build[\\/].*" checks=".*"/>
|
||||
<suppress files="target[\\/].*" checks=".*"/>
|
||||
<suppress files=".+\.(jar|git|ico|p12|gif|jks|jpg|svg|log)" checks="NoHttp"/>
|
||||
|
||||
<!-- Platform-scaffolded paths that use internal Kubernetes service URLs (http only) -->
|
||||
<suppress files="k6[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="overlays[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="docs[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="config[\\/].*" checks="NoHttp"/>
|
||||
</suppressions>
|
||||
2
src/currencyservice/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
client.js
|
||||
node_modules/
|
||||
1
src/currencyservice/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
45
src/currencyservice/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# Copyright 2020 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Define a default value so it's not empty if the builder fails to provide it
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:20.20.1-alpine@sha256:b88333c42c23fbd91596ebd7fd10de239cedab9617de04142dde7315e3bc0afa AS builder
|
||||
|
||||
# Some packages (e.g. @google-cloud/profiler) require additional
|
||||
# deps for post-install scripts
|
||||
RUN apk add --update --no-cache \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --only=production
|
||||
|
||||
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
|
||||
RUN apk add --no-cache nodejs
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 7000
|
||||
|
||||
ENTRYPOINT [ "node", "server.js" ]
|
||||
68
src/currencyservice/client.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2015 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
require('@google-cloud/trace-agent').start();
|
||||
|
||||
const path = require('path');
|
||||
const grpc = require('grpc');
|
||||
const pino = require('pino');
|
||||
|
||||
const PROTO_PATH = path.join(__dirname, './proto/demo.proto');
|
||||
const PORT = 7000;
|
||||
|
||||
const shopProto = grpc.load(PROTO_PATH).hipstershop;
|
||||
const client = new shopProto.CurrencyService(`localhost:${PORT}`,
|
||||
grpc.credentials.createInsecure());
|
||||
|
||||
const logger = pino({
|
||||
name: 'currencyservice-client',
|
||||
messageKey: 'message',
|
||||
formatters: {
|
||||
level (logLevelString, logLevelNum) {
|
||||
return { severity: logLevelString }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const request = {
|
||||
from: {
|
||||
currency_code: 'CHF',
|
||||
units: 300,
|
||||
nanos: 0
|
||||
},
|
||||
to_code: 'EUR'
|
||||
};
|
||||
|
||||
function _moneyToString (m) {
|
||||
return `${m.units}.${m.nanos.toString().padStart(9,'0')} ${m.currency_code}`;
|
||||
}
|
||||
|
||||
client.getSupportedCurrencies({}, (err, response) => {
|
||||
if (err) {
|
||||
logger.error(`Error in getSupportedCurrencies: ${err}`);
|
||||
} else {
|
||||
logger.info(`Currency codes: ${response.currency_codes}`);
|
||||
}
|
||||
});
|
||||
|
||||
client.convert(request, (err, response) => {
|
||||
if (err) {
|
||||
logger.error(`Error in convert: ${err}`);
|
||||
} else {
|
||||
logger.log(`Convert: ${_moneyToString(request.from)} to ${_moneyToString(response)}`);
|
||||
}
|
||||
});
|
||||
35
src/currencyservice/data/currency_conversion.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"EUR": "1.0",
|
||||
"USD": "1.1305",
|
||||
"JPY": "126.40",
|
||||
"BGN": "1.9558",
|
||||
"CZK": "25.592",
|
||||
"DKK": "7.4609",
|
||||
"GBP": "0.85970",
|
||||
"HUF": "315.51",
|
||||
"PLN": "4.2996",
|
||||
"RON": "4.7463",
|
||||
"SEK": "10.5375",
|
||||
"CHF": "1.1360",
|
||||
"ISK": "136.80",
|
||||
"NOK": "9.8040",
|
||||
"HRK": "7.4210",
|
||||
"RUB": "74.4208",
|
||||
"TRY": "6.1247",
|
||||
"AUD": "1.6072",
|
||||
"BRL": "4.2682",
|
||||
"CAD": "1.5128",
|
||||
"CNY": "7.5857",
|
||||
"HKD": "8.8743",
|
||||
"IDR": "15999.40",
|
||||
"ILS": "4.0875",
|
||||
"INR": "79.4320",
|
||||
"KRW": "1275.05",
|
||||
"MXN": "21.7999",
|
||||
"MYR": "4.6289",
|
||||
"NZD": "1.6679",
|
||||
"PHP": "59.083",
|
||||
"SGD": "1.5349",
|
||||
"THB": "36.012",
|
||||
"ZAR": "16.0583"
|
||||
}
|
||||
23
src/currencyservice/genproto.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash -eu
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# [START gke_currencyservice_genproto]
|
||||
|
||||
# protos are loaded dynamically for node, simply copies over the proto.
|
||||
mkdir -p proto
|
||||
cp -r ../../protos/* ./proto
|
||||
|
||||
# [END gke_currencyservice_genproto]
|
||||
3563
src/currencyservice/package-lock.json
generated
Normal file
27
src/currencyservice/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "grpc-currency-service",
|
||||
"version": "0.1.0",
|
||||
"description": "A gRPC currency conversion microservice",
|
||||
"repository": "https://github.com/GoogleCloudPlatform/microservices-demo",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/profiler": "6.0.4",
|
||||
"@google-cloud/trace-agent": "8.0.0",
|
||||
"@grpc/grpc-js": "1.14.3",
|
||||
"@grpc/proto-loader": "0.8.0",
|
||||
"async": "3.2.6",
|
||||
"google-protobuf": "4.0.2",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/exporter-otlp-grpc": "0.26.0",
|
||||
"@opentelemetry/instrumentation-grpc": "0.213.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.0",
|
||||
"@opentelemetry/sdk-node": "0.213.0",
|
||||
"pino": "10.3.1",
|
||||
"xml2js": "0.6.2"
|
||||
}
|
||||
}
|
||||
260
src/currencyservice/proto/demo.proto
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright 2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package hipstershop;
|
||||
|
||||
// -----------------Cart service-----------------
|
||||
|
||||
service CartService {
|
||||
rpc AddItem(AddItemRequest) returns (Empty) {}
|
||||
rpc GetCart(GetCartRequest) returns (Cart) {}
|
||||
rpc EmptyCart(EmptyCartRequest) returns (Empty) {}
|
||||
}
|
||||
|
||||
message CartItem {
|
||||
string product_id = 1;
|
||||
int32 quantity = 2;
|
||||
}
|
||||
|
||||
message AddItemRequest {
|
||||
string user_id = 1;
|
||||
CartItem item = 2;
|
||||
}
|
||||
|
||||
message EmptyCartRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message GetCartRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message Cart {
|
||||
string user_id = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
|
||||
// ---------------Recommendation service----------
|
||||
|
||||
service RecommendationService {
|
||||
rpc ListRecommendations(ListRecommendationsRequest) returns (ListRecommendationsResponse){}
|
||||
}
|
||||
|
||||
message ListRecommendationsRequest {
|
||||
string user_id = 1;
|
||||
repeated string product_ids = 2;
|
||||
}
|
||||
|
||||
message ListRecommendationsResponse {
|
||||
repeated string product_ids = 1;
|
||||
}
|
||||
|
||||
// ---------------Product Catalog----------------
|
||||
|
||||
service ProductCatalogService {
|
||||
rpc ListProducts(Empty) returns (ListProductsResponse) {}
|
||||
rpc GetProduct(GetProductRequest) returns (Product) {}
|
||||
rpc SearchProducts(SearchProductsRequest) returns (SearchProductsResponse) {}
|
||||
}
|
||||
|
||||
message Product {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string description = 3;
|
||||
string picture = 4;
|
||||
Money price_usd = 5;
|
||||
|
||||
// Categories such as "clothing" or "kitchen" that can be used to look up
|
||||
// other related products.
|
||||
repeated string categories = 6;
|
||||
}
|
||||
|
||||
message ListProductsResponse {
|
||||
repeated Product products = 1;
|
||||
}
|
||||
|
||||
message GetProductRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message SearchProductsRequest {
|
||||
string query = 1;
|
||||
}
|
||||
|
||||
message SearchProductsResponse {
|
||||
repeated Product results = 1;
|
||||
}
|
||||
|
||||
// ---------------Shipping Service----------
|
||||
|
||||
service ShippingService {
|
||||
rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse) {}
|
||||
rpc ShipOrder(ShipOrderRequest) returns (ShipOrderResponse) {}
|
||||
}
|
||||
|
||||
message GetQuoteRequest {
|
||||
Address address = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message GetQuoteResponse {
|
||||
Money cost_usd = 1;
|
||||
}
|
||||
|
||||
message ShipOrderRequest {
|
||||
Address address = 1;
|
||||
repeated CartItem items = 2;
|
||||
}
|
||||
|
||||
message ShipOrderResponse {
|
||||
string tracking_id = 1;
|
||||
}
|
||||
|
||||
message Address {
|
||||
string street_address = 1;
|
||||
string city = 2;
|
||||
string state = 3;
|
||||
string country = 4;
|
||||
int32 zip_code = 5;
|
||||
}
|
||||
|
||||
// -----------------Currency service-----------------
|
||||
|
||||
service CurrencyService {
|
||||
rpc GetSupportedCurrencies(Empty) returns (GetSupportedCurrenciesResponse) {}
|
||||
rpc Convert(CurrencyConversionRequest) returns (Money) {}
|
||||
}
|
||||
|
||||
// Represents an amount of money with its currency type.
|
||||
message Money {
|
||||
// The 3-letter currency code defined in ISO 4217.
|
||||
string currency_code = 1;
|
||||
|
||||
// The whole units of the amount.
|
||||
// For example if `currencyCode` is `"USD"`, then 1 unit is one US dollar.
|
||||
int64 units = 2;
|
||||
|
||||
// Number of nano (10^-9) units of the amount.
|
||||
// The value must be between -999,999,999 and +999,999,999 inclusive.
|
||||
// If `units` is positive, `nanos` must be positive or zero.
|
||||
// If `units` is zero, `nanos` can be positive, zero, or negative.
|
||||
// If `units` is negative, `nanos` must be negative or zero.
|
||||
// For example $-1.75 is represented as `units`=-1 and `nanos`=-750,000,000.
|
||||
int32 nanos = 3;
|
||||
}
|
||||
|
||||
message GetSupportedCurrenciesResponse {
|
||||
// The 3-letter currency code defined in ISO 4217.
|
||||
repeated string currency_codes = 1;
|
||||
}
|
||||
|
||||
message CurrencyConversionRequest {
|
||||
Money from = 1;
|
||||
|
||||
// The 3-letter currency code defined in ISO 4217.
|
||||
string to_code = 2;
|
||||
}
|
||||
|
||||
// -------------Payment service-----------------
|
||||
|
||||
service PaymentService {
|
||||
rpc Charge(ChargeRequest) returns (ChargeResponse) {}
|
||||
}
|
||||
|
||||
message CreditCardInfo {
|
||||
string credit_card_number = 1;
|
||||
int32 credit_card_cvv = 2;
|
||||
int32 credit_card_expiration_year = 3;
|
||||
int32 credit_card_expiration_month = 4;
|
||||
}
|
||||
|
||||
message ChargeRequest {
|
||||
Money amount = 1;
|
||||
CreditCardInfo credit_card = 2;
|
||||
}
|
||||
|
||||
message ChargeResponse {
|
||||
string transaction_id = 1;
|
||||
}
|
||||
|
||||
// -------------Email service-----------------
|
||||
|
||||
service EmailService {
|
||||
rpc SendOrderConfirmation(SendOrderConfirmationRequest) returns (Empty) {}
|
||||
}
|
||||
|
||||
message OrderItem {
|
||||
CartItem item = 1;
|
||||
Money cost = 2;
|
||||
}
|
||||
|
||||
message OrderResult {
|
||||
string order_id = 1;
|
||||
string shipping_tracking_id = 2;
|
||||
Money shipping_cost = 3;
|
||||
Address shipping_address = 4;
|
||||
repeated OrderItem items = 5;
|
||||
}
|
||||
|
||||
message SendOrderConfirmationRequest {
|
||||
string email = 1;
|
||||
OrderResult order = 2;
|
||||
}
|
||||
|
||||
|
||||
// -------------Checkout service-----------------
|
||||
|
||||
service CheckoutService {
|
||||
rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse) {}
|
||||
}
|
||||
|
||||
message PlaceOrderRequest {
|
||||
string user_id = 1;
|
||||
string user_currency = 2;
|
||||
|
||||
Address address = 3;
|
||||
string email = 5;
|
||||
CreditCardInfo credit_card = 6;
|
||||
}
|
||||
|
||||
message PlaceOrderResponse {
|
||||
OrderResult order = 1;
|
||||
}
|
||||
|
||||
// ------------Ad service------------------
|
||||
|
||||
service AdService {
|
||||
rpc GetAds(AdRequest) returns (AdResponse) {}
|
||||
}
|
||||
|
||||
message AdRequest {
|
||||
// List of important key words from the current page describing the context.
|
||||
repeated string context_keys = 1;
|
||||
}
|
||||
|
||||
message AdResponse {
|
||||
repeated Ad ads = 1;
|
||||
}
|
||||
|
||||
message Ad {
|
||||
// url to redirect to when an ad is clicked.
|
||||
string redirect_url = 1;
|
||||
|
||||
// short advertisement text to display.
|
||||
string text = 2;
|
||||
}
|
||||
43
src/currencyservice/proto/grpc/health/v1/health.proto
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright 2015 The gRPC Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// The canonical version of this proto can be found at
|
||||
// https://github.com/grpc/grpc-proto/blob/master/grpc/health/v1/health.proto
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package grpc.health.v1;
|
||||
|
||||
option csharp_namespace = "Grpc.Health.V1";
|
||||
option go_package = "google.golang.org/grpc/health/grpc_health_v1";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "HealthProto";
|
||||
option java_package = "io.grpc.health.v1";
|
||||
|
||||
message HealthCheckRequest {
|
||||
string service = 1;
|
||||
}
|
||||
|
||||
message HealthCheckResponse {
|
||||
enum ServingStatus {
|
||||
UNKNOWN = 0;
|
||||
SERVING = 1;
|
||||
NOT_SERVING = 2;
|
||||
}
|
||||
ServingStatus status = 1;
|
||||
}
|
||||
|
||||
service Health {
|
||||
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
}
|
||||
198
src/currencyservice/server.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2018 Google LLC.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const pino = require('pino');
|
||||
const logger = pino({
|
||||
name: 'currencyservice-server',
|
||||
messageKey: 'message',
|
||||
formatters: {
|
||||
level (logLevelString, logLevelNum) {
|
||||
return { severity: logLevelString }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if(process.env.DISABLE_PROFILER) {
|
||||
logger.info("Profiler disabled.")
|
||||
}
|
||||
else {
|
||||
logger.info("Profiler enabled.")
|
||||
require('@google-cloud/profiler').start({
|
||||
serviceContext: {
|
||||
service: 'currencyservice',
|
||||
version: '1.0.0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register GRPC OTel Instrumentation for trace propagation
|
||||
// regardless of whether tracing is emitted.
|
||||
const { GrpcInstrumentation } = require('@opentelemetry/instrumentation-grpc');
|
||||
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
|
||||
|
||||
registerInstrumentations({
|
||||
instrumentations: [new GrpcInstrumentation()]
|
||||
});
|
||||
|
||||
if(process.env.ENABLE_TRACING == "1") {
|
||||
logger.info("Tracing enabled.")
|
||||
|
||||
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
||||
|
||||
const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
|
||||
|
||||
const opentelemetry = require('@opentelemetry/sdk-node');
|
||||
|
||||
const { OTLPTraceExporter } = require('@opentelemetry/exporter-otlp-grpc');
|
||||
|
||||
const collectorUrl = process.env.COLLECTOR_SERVICE_ADDR;
|
||||
const traceExporter = new OTLPTraceExporter({url: collectorUrl});
|
||||
const sdk = new opentelemetry.NodeSDK({
|
||||
resource: resourceFromAttributes({
|
||||
[ ATTR_SERVICE_NAME ]: process.env.OTEL_SERVICE_NAME || 'currencyservice',
|
||||
}),
|
||||
traceExporter: traceExporter,
|
||||
});
|
||||
|
||||
sdk.start()
|
||||
}
|
||||
else {
|
||||
logger.info("Tracing disabled.")
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const protoLoader = require('@grpc/proto-loader');
|
||||
|
||||
const MAIN_PROTO_PATH = path.join(__dirname, './proto/demo.proto');
|
||||
const HEALTH_PROTO_PATH = path.join(__dirname, './proto/grpc/health/v1/health.proto');
|
||||
|
||||
const PORT = process.env.PORT;
|
||||
|
||||
const shopProto = _loadProto(MAIN_PROTO_PATH).hipstershop;
|
||||
const healthProto = _loadProto(HEALTH_PROTO_PATH).grpc.health.v1;
|
||||
|
||||
/**
|
||||
* Helper function that loads a protobuf file.
|
||||
*/
|
||||
function _loadProto (path) {
|
||||
const packageDefinition = protoLoader.loadSync(
|
||||
path,
|
||||
{
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true
|
||||
}
|
||||
);
|
||||
return grpc.loadPackageDefinition(packageDefinition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that gets currency data from a stored JSON file
|
||||
* Uses public data from European Central Bank
|
||||
*/
|
||||
function _getCurrencyData (callback) {
|
||||
const data = require('./data/currency_conversion.json');
|
||||
callback(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that handles decimal/fractional carrying
|
||||
*/
|
||||
function _carry (amount) {
|
||||
const fractionSize = Math.pow(10, 9);
|
||||
amount.nanos += (amount.units % 1) * fractionSize;
|
||||
amount.units = Math.floor(amount.units) + Math.floor(amount.nanos / fractionSize);
|
||||
amount.nanos = amount.nanos % fractionSize;
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the supported currencies
|
||||
*/
|
||||
function getSupportedCurrencies (call, callback) {
|
||||
logger.info('Getting supported currencies...');
|
||||
_getCurrencyData((data) => {
|
||||
callback(null, {currency_codes: Object.keys(data)});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts between currencies
|
||||
*/
|
||||
function convert (call, callback) {
|
||||
try {
|
||||
_getCurrencyData((data) => {
|
||||
const request = call.request;
|
||||
|
||||
// Convert: from_currency --> EUR
|
||||
const from = request.from;
|
||||
const euros = _carry({
|
||||
units: from.units / data[from.currency_code],
|
||||
nanos: from.nanos / data[from.currency_code]
|
||||
});
|
||||
|
||||
euros.nanos = Math.round(euros.nanos);
|
||||
|
||||
// Convert: EUR --> to_currency
|
||||
const result = _carry({
|
||||
units: euros.units * data[request.to_code],
|
||||
nanos: euros.nanos * data[request.to_code]
|
||||
});
|
||||
|
||||
result.units = Math.floor(result.units);
|
||||
result.nanos = Math.floor(result.nanos);
|
||||
result.currency_code = request.to_code;
|
||||
|
||||
logger.info(`conversion request successful`);
|
||||
callback(null, result);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`conversion request failed: ${err}`);
|
||||
callback(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint for health checks
|
||||
*/
|
||||
function check (call, callback) {
|
||||
callback(null, { status: 'SERVING' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an RPC server that receives requests for the
|
||||
* CurrencyConverter service at the sample server port
|
||||
*/
|
||||
function main () {
|
||||
logger.info(`Starting gRPC server on port ${PORT}...`);
|
||||
const server = new grpc.Server();
|
||||
server.addService(shopProto.CurrencyService.service, {getSupportedCurrencies, convert});
|
||||
server.addService(healthProto.Health.service, {check});
|
||||
|
||||
server.bindAsync(
|
||||
`[::]:${PORT}`,
|
||||
grpc.ServerCredentials.createInsecure(),
|
||||
function() {
|
||||
logger.info(`CurrencyService gRPC server started on port ${PORT}`);
|
||||
server.start();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
54
src/emailservice/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright 2020 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Define a default value so it's not empty if the builder fails to provide it
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
|
||||
FROM --platform=$BUILDPLATFORM python:3.14.3-alpine@sha256:faee120f7885a06fcc9677922331391fa690d911c020abb9e8025ff3d908e510 AS base
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache g++ linux-headers \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# get packages
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
FROM base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Enable Profiler
|
||||
ENV ENABLE_PROFILER=1
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache libstdc++ \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /email_server
|
||||
|
||||
# Grab packages from builder
|
||||
COPY --from=builder /usr/local/lib/python3.14/ /usr/local/lib/python3.14/
|
||||
|
||||
# Add the application
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT [ "python", "email_server.py" ]
|
||||
121
src/emailservice/demo_pb2.py
Executable file
822
src/emailservice/demo_pb2_grpc.py
Executable file
@@ -0,0 +1,822 @@
|
||||
#!/usr/bin/python
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
import demo_pb2 as demo__pb2
|
||||
|
||||
|
||||
class CartServiceStub(object):
|
||||
"""-----------------Cart service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.AddItem = channel.unary_unary(
|
||||
'/hipstershop.CartService/AddItem',
|
||||
request_serializer=demo__pb2.AddItemRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.Empty.FromString,
|
||||
)
|
||||
self.GetCart = channel.unary_unary(
|
||||
'/hipstershop.CartService/GetCart',
|
||||
request_serializer=demo__pb2.GetCartRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.Cart.FromString,
|
||||
)
|
||||
self.EmptyCart = channel.unary_unary(
|
||||
'/hipstershop.CartService/EmptyCart',
|
||||
request_serializer=demo__pb2.EmptyCartRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.Empty.FromString,
|
||||
)
|
||||
|
||||
|
||||
class CartServiceServicer(object):
|
||||
"""-----------------Cart service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def AddItem(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetCart(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def EmptyCart(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_CartServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'AddItem': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.AddItem,
|
||||
request_deserializer=demo__pb2.AddItemRequest.FromString,
|
||||
response_serializer=demo__pb2.Empty.SerializeToString,
|
||||
),
|
||||
'GetCart': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetCart,
|
||||
request_deserializer=demo__pb2.GetCartRequest.FromString,
|
||||
response_serializer=demo__pb2.Cart.SerializeToString,
|
||||
),
|
||||
'EmptyCart': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.EmptyCart,
|
||||
request_deserializer=demo__pb2.EmptyCartRequest.FromString,
|
||||
response_serializer=demo__pb2.Empty.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.CartService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class CartService(object):
|
||||
"""-----------------Cart service-----------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def AddItem(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.CartService/AddItem',
|
||||
demo__pb2.AddItemRequest.SerializeToString,
|
||||
demo__pb2.Empty.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def GetCart(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.CartService/GetCart',
|
||||
demo__pb2.GetCartRequest.SerializeToString,
|
||||
demo__pb2.Cart.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def EmptyCart(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.CartService/EmptyCart',
|
||||
demo__pb2.EmptyCartRequest.SerializeToString,
|
||||
demo__pb2.Empty.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class RecommendationServiceStub(object):
|
||||
"""---------------Recommendation service----------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.ListRecommendations = channel.unary_unary(
|
||||
'/hipstershop.RecommendationService/ListRecommendations',
|
||||
request_serializer=demo__pb2.ListRecommendationsRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.ListRecommendationsResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class RecommendationServiceServicer(object):
|
||||
"""---------------Recommendation service----------
|
||||
|
||||
"""
|
||||
|
||||
def ListRecommendations(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_RecommendationServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'ListRecommendations': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListRecommendations,
|
||||
request_deserializer=demo__pb2.ListRecommendationsRequest.FromString,
|
||||
response_serializer=demo__pb2.ListRecommendationsResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.RecommendationService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class RecommendationService(object):
|
||||
"""---------------Recommendation service----------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def ListRecommendations(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.RecommendationService/ListRecommendations',
|
||||
demo__pb2.ListRecommendationsRequest.SerializeToString,
|
||||
demo__pb2.ListRecommendationsResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class ProductCatalogServiceStub(object):
|
||||
"""---------------Product Catalog----------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.ListProducts = channel.unary_unary(
|
||||
'/hipstershop.ProductCatalogService/ListProducts',
|
||||
request_serializer=demo__pb2.Empty.SerializeToString,
|
||||
response_deserializer=demo__pb2.ListProductsResponse.FromString,
|
||||
)
|
||||
self.GetProduct = channel.unary_unary(
|
||||
'/hipstershop.ProductCatalogService/GetProduct',
|
||||
request_serializer=demo__pb2.GetProductRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.Product.FromString,
|
||||
)
|
||||
self.SearchProducts = channel.unary_unary(
|
||||
'/hipstershop.ProductCatalogService/SearchProducts',
|
||||
request_serializer=demo__pb2.SearchProductsRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.SearchProductsResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class ProductCatalogServiceServicer(object):
|
||||
"""---------------Product Catalog----------------
|
||||
|
||||
"""
|
||||
|
||||
def ListProducts(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetProduct(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def SearchProducts(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_ProductCatalogServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'ListProducts': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListProducts,
|
||||
request_deserializer=demo__pb2.Empty.FromString,
|
||||
response_serializer=demo__pb2.ListProductsResponse.SerializeToString,
|
||||
),
|
||||
'GetProduct': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetProduct,
|
||||
request_deserializer=demo__pb2.GetProductRequest.FromString,
|
||||
response_serializer=demo__pb2.Product.SerializeToString,
|
||||
),
|
||||
'SearchProducts': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SearchProducts,
|
||||
request_deserializer=demo__pb2.SearchProductsRequest.FromString,
|
||||
response_serializer=demo__pb2.SearchProductsResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.ProductCatalogService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class ProductCatalogService(object):
|
||||
"""---------------Product Catalog----------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def ListProducts(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.ProductCatalogService/ListProducts',
|
||||
demo__pb2.Empty.SerializeToString,
|
||||
demo__pb2.ListProductsResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def GetProduct(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.ProductCatalogService/GetProduct',
|
||||
demo__pb2.GetProductRequest.SerializeToString,
|
||||
demo__pb2.Product.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def SearchProducts(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.ProductCatalogService/SearchProducts',
|
||||
demo__pb2.SearchProductsRequest.SerializeToString,
|
||||
demo__pb2.SearchProductsResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class ShippingServiceStub(object):
|
||||
"""---------------Shipping Service----------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.GetQuote = channel.unary_unary(
|
||||
'/hipstershop.ShippingService/GetQuote',
|
||||
request_serializer=demo__pb2.GetQuoteRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.GetQuoteResponse.FromString,
|
||||
)
|
||||
self.ShipOrder = channel.unary_unary(
|
||||
'/hipstershop.ShippingService/ShipOrder',
|
||||
request_serializer=demo__pb2.ShipOrderRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.ShipOrderResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class ShippingServiceServicer(object):
|
||||
"""---------------Shipping Service----------
|
||||
|
||||
"""
|
||||
|
||||
def GetQuote(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ShipOrder(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_ShippingServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'GetQuote': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetQuote,
|
||||
request_deserializer=demo__pb2.GetQuoteRequest.FromString,
|
||||
response_serializer=demo__pb2.GetQuoteResponse.SerializeToString,
|
||||
),
|
||||
'ShipOrder': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ShipOrder,
|
||||
request_deserializer=demo__pb2.ShipOrderRequest.FromString,
|
||||
response_serializer=demo__pb2.ShipOrderResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.ShippingService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class ShippingService(object):
|
||||
"""---------------Shipping Service----------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def GetQuote(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.ShippingService/GetQuote',
|
||||
demo__pb2.GetQuoteRequest.SerializeToString,
|
||||
demo__pb2.GetQuoteResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def ShipOrder(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.ShippingService/ShipOrder',
|
||||
demo__pb2.ShipOrderRequest.SerializeToString,
|
||||
demo__pb2.ShipOrderResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class CurrencyServiceStub(object):
|
||||
"""-----------------Currency service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.GetSupportedCurrencies = channel.unary_unary(
|
||||
'/hipstershop.CurrencyService/GetSupportedCurrencies',
|
||||
request_serializer=demo__pb2.Empty.SerializeToString,
|
||||
response_deserializer=demo__pb2.GetSupportedCurrenciesResponse.FromString,
|
||||
)
|
||||
self.Convert = channel.unary_unary(
|
||||
'/hipstershop.CurrencyService/Convert',
|
||||
request_serializer=demo__pb2.CurrencyConversionRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.Money.FromString,
|
||||
)
|
||||
|
||||
|
||||
class CurrencyServiceServicer(object):
|
||||
"""-----------------Currency service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def GetSupportedCurrencies(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Convert(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_CurrencyServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'GetSupportedCurrencies': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetSupportedCurrencies,
|
||||
request_deserializer=demo__pb2.Empty.FromString,
|
||||
response_serializer=demo__pb2.GetSupportedCurrenciesResponse.SerializeToString,
|
||||
),
|
||||
'Convert': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Convert,
|
||||
request_deserializer=demo__pb2.CurrencyConversionRequest.FromString,
|
||||
response_serializer=demo__pb2.Money.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.CurrencyService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class CurrencyService(object):
|
||||
"""-----------------Currency service-----------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def GetSupportedCurrencies(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.CurrencyService/GetSupportedCurrencies',
|
||||
demo__pb2.Empty.SerializeToString,
|
||||
demo__pb2.GetSupportedCurrenciesResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def Convert(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.CurrencyService/Convert',
|
||||
demo__pb2.CurrencyConversionRequest.SerializeToString,
|
||||
demo__pb2.Money.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class PaymentServiceStub(object):
|
||||
"""-------------Payment service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Charge = channel.unary_unary(
|
||||
'/hipstershop.PaymentService/Charge',
|
||||
request_serializer=demo__pb2.ChargeRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.ChargeResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class PaymentServiceServicer(object):
|
||||
"""-------------Payment service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def Charge(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_PaymentServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'Charge': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Charge,
|
||||
request_deserializer=demo__pb2.ChargeRequest.FromString,
|
||||
response_serializer=demo__pb2.ChargeResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.PaymentService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class PaymentService(object):
|
||||
"""-------------Payment service-----------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def Charge(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.PaymentService/Charge',
|
||||
demo__pb2.ChargeRequest.SerializeToString,
|
||||
demo__pb2.ChargeResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class EmailServiceStub(object):
|
||||
"""-------------Email service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.SendOrderConfirmation = channel.unary_unary(
|
||||
'/hipstershop.EmailService/SendOrderConfirmation',
|
||||
request_serializer=demo__pb2.SendOrderConfirmationRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.Empty.FromString,
|
||||
)
|
||||
|
||||
|
||||
class EmailServiceServicer(object):
|
||||
"""-------------Email service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def SendOrderConfirmation(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_EmailServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'SendOrderConfirmation': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SendOrderConfirmation,
|
||||
request_deserializer=demo__pb2.SendOrderConfirmationRequest.FromString,
|
||||
response_serializer=demo__pb2.Empty.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.EmailService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class EmailService(object):
|
||||
"""-------------Email service-----------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def SendOrderConfirmation(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.EmailService/SendOrderConfirmation',
|
||||
demo__pb2.SendOrderConfirmationRequest.SerializeToString,
|
||||
demo__pb2.Empty.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class CheckoutServiceStub(object):
|
||||
"""-------------Checkout service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.PlaceOrder = channel.unary_unary(
|
||||
'/hipstershop.CheckoutService/PlaceOrder',
|
||||
request_serializer=demo__pb2.PlaceOrderRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.PlaceOrderResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class CheckoutServiceServicer(object):
|
||||
"""-------------Checkout service-----------------
|
||||
|
||||
"""
|
||||
|
||||
def PlaceOrder(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_CheckoutServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'PlaceOrder': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.PlaceOrder,
|
||||
request_deserializer=demo__pb2.PlaceOrderRequest.FromString,
|
||||
response_serializer=demo__pb2.PlaceOrderResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.CheckoutService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class CheckoutService(object):
|
||||
"""-------------Checkout service-----------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def PlaceOrder(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.CheckoutService/PlaceOrder',
|
||||
demo__pb2.PlaceOrderRequest.SerializeToString,
|
||||
demo__pb2.PlaceOrderResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class AdServiceStub(object):
|
||||
"""------------Ad service------------------
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.GetAds = channel.unary_unary(
|
||||
'/hipstershop.AdService/GetAds',
|
||||
request_serializer=demo__pb2.AdRequest.SerializeToString,
|
||||
response_deserializer=demo__pb2.AdResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class AdServiceServicer(object):
|
||||
"""------------Ad service------------------
|
||||
|
||||
"""
|
||||
|
||||
def GetAds(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_AdServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'GetAds': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetAds,
|
||||
request_deserializer=demo__pb2.AdRequest.FromString,
|
||||
response_serializer=demo__pb2.AdResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'hipstershop.AdService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class AdService(object):
|
||||
"""------------Ad service------------------
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def GetAds(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/hipstershop.AdService/GetAds',
|
||||
demo__pb2.AdRequest.SerializeToString,
|
||||
demo__pb2.AdResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
39
src/emailservice/email_client.py
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/python
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import grpc
|
||||
|
||||
import demo_pb2
|
||||
import demo_pb2_grpc
|
||||
|
||||
from logger import getJSONLogger
|
||||
logger = getJSONLogger('emailservice-client')
|
||||
|
||||
def send_confirmation_email(email, order):
|
||||
channel = grpc.insecure_channel('[::]:8080')
|
||||
stub = demo_pb2_grpc.EmailServiceStub(channel)
|
||||
try:
|
||||
response = stub.SendOrderConfirmation(demo_pb2.SendOrderConfirmationRequest(
|
||||
email = email,
|
||||
order = order
|
||||
))
|
||||
logger.info('Request sent.')
|
||||
except grpc.RpcError as err:
|
||||
logger.error(err.details())
|
||||
logger.error('{}, {}'.format(err.code().name, err.code().value))
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info('Client for email service.')
|
||||
200
src/emailservice/email_server.py
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/python
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from concurrent import futures
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import grpc
|
||||
import traceback
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape, TemplateError
|
||||
from google.api_core.exceptions import GoogleAPICallError
|
||||
from google.auth.exceptions import DefaultCredentialsError
|
||||
|
||||
import demo_pb2
|
||||
import demo_pb2_grpc
|
||||
from grpc_health.v1 import health_pb2
|
||||
from grpc_health.v1 import health_pb2_grpc
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.instrumentation.grpc import GrpcInstrumentorServer
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
|
||||
# @TODO: Temporarily removed in https://github.com/GoogleCloudPlatform/microservices-demo/pull/3196
|
||||
# import googlecloudprofiler
|
||||
|
||||
from logger import getJSONLogger
|
||||
logger = getJSONLogger('emailservice-server')
|
||||
|
||||
# Loads confirmation email template from file
|
||||
env = Environment(
|
||||
loader=FileSystemLoader('templates'),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
template = env.get_template('confirmation.html')
|
||||
|
||||
class BaseEmailService(demo_pb2_grpc.EmailServiceServicer):
|
||||
def Check(self, request, context):
|
||||
return health_pb2.HealthCheckResponse(
|
||||
status=health_pb2.HealthCheckResponse.SERVING)
|
||||
|
||||
def Watch(self, request, context):
|
||||
return health_pb2.HealthCheckResponse(
|
||||
status=health_pb2.HealthCheckResponse.UNIMPLEMENTED)
|
||||
|
||||
class EmailService(BaseEmailService):
|
||||
def __init__(self):
|
||||
raise Exception('cloud mail client not implemented')
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def send_email(client, email_address, content):
|
||||
response = client.send_message(
|
||||
sender = client.sender_path(project_id, region, sender_id),
|
||||
envelope_from_authority = '',
|
||||
header_from_authority = '',
|
||||
envelope_from_address = from_address,
|
||||
simple_message = {
|
||||
"from": {
|
||||
"address_spec": from_address,
|
||||
},
|
||||
"to": [{
|
||||
"address_spec": email_address
|
||||
}],
|
||||
"subject": "Your Confirmation Email",
|
||||
"html_body": content
|
||||
}
|
||||
)
|
||||
logger.info("Message sent: {}".format(response.rfc822_message_id))
|
||||
|
||||
def SendOrderConfirmation(self, request, context):
|
||||
email = request.email
|
||||
order = request.order
|
||||
|
||||
try:
|
||||
confirmation = template.render(order = order)
|
||||
except TemplateError as err:
|
||||
context.set_details("An error occurred when preparing the confirmation mail.")
|
||||
logger.error(err.message)
|
||||
context.set_code(grpc.StatusCode.INTERNAL)
|
||||
return demo_pb2.Empty()
|
||||
|
||||
try:
|
||||
EmailService.send_email(self.client, email, confirmation)
|
||||
except GoogleAPICallError as err:
|
||||
context.set_details("An error occurred when sending the email.")
|
||||
print(err.message)
|
||||
context.set_code(grpc.StatusCode.INTERNAL)
|
||||
return demo_pb2.Empty()
|
||||
|
||||
return demo_pb2.Empty()
|
||||
|
||||
class DummyEmailService(BaseEmailService):
|
||||
def SendOrderConfirmation(self, request, context):
|
||||
logger.info('A request to send order confirmation email to {} has been received.'.format(request.email))
|
||||
return demo_pb2.Empty()
|
||||
|
||||
class HealthCheck():
|
||||
def Check(self, request, context):
|
||||
return health_pb2.HealthCheckResponse(
|
||||
status=health_pb2.HealthCheckResponse.SERVING)
|
||||
|
||||
def start(dummy_mode):
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10),)
|
||||
service = None
|
||||
if dummy_mode:
|
||||
service = DummyEmailService()
|
||||
else:
|
||||
raise Exception('non-dummy mode not implemented yet')
|
||||
|
||||
demo_pb2_grpc.add_EmailServiceServicer_to_server(service, server)
|
||||
health_pb2_grpc.add_HealthServicer_to_server(service, server)
|
||||
|
||||
port = os.environ.get('PORT', "8080")
|
||||
logger.info("listening on port: "+port)
|
||||
server.add_insecure_port('[::]:'+port)
|
||||
server.start()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
server.stop(0)
|
||||
|
||||
def initStackdriverProfiling():
|
||||
project_id = None
|
||||
try:
|
||||
project_id = os.environ["GCP_PROJECT_ID"]
|
||||
except KeyError:
|
||||
# Environment variable not set
|
||||
pass
|
||||
|
||||
# @TODO: Temporarily removed in https://github.com/GoogleCloudPlatform/microservices-demo/pull/3196
|
||||
# for retry in range(1,4):
|
||||
# try:
|
||||
# if project_id:
|
||||
# googlecloudprofiler.start(service='email_server', service_version='1.0.0', verbose=0, project_id=project_id)
|
||||
# else:
|
||||
# googlecloudprofiler.start(service='email_server', service_version='1.0.0', verbose=0)
|
||||
# logger.info("Successfully started Stackdriver Profiler.")
|
||||
# return
|
||||
# except (BaseException) as exc:
|
||||
# logger.info("Unable to start Stackdriver Profiler Python agent. " + str(exc))
|
||||
# if (retry < 4):
|
||||
# logger.info("Sleeping %d to retry initializing Stackdriver Profiler"%(retry*10))
|
||||
# time.sleep (1)
|
||||
# else:
|
||||
# logger.warning("Could not initialize Stackdriver Profiler after retrying, giving up")
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info('starting the email service in dummy mode.')
|
||||
|
||||
# Profiler
|
||||
try:
|
||||
if "DISABLE_PROFILER" in os.environ:
|
||||
raise KeyError()
|
||||
else:
|
||||
logger.info("Profiler enabled.")
|
||||
initStackdriverProfiling()
|
||||
except KeyError:
|
||||
logger.info("Profiler disabled.")
|
||||
|
||||
# Tracing
|
||||
try:
|
||||
if os.environ["ENABLE_TRACING"] == "1":
|
||||
otel_endpoint = os.getenv("COLLECTOR_SERVICE_ADDR", "localhost:4317")
|
||||
trace.set_tracer_provider(TracerProvider())
|
||||
trace.get_tracer_provider().add_span_processor(
|
||||
BatchSpanProcessor(
|
||||
OTLPSpanExporter(
|
||||
endpoint = otel_endpoint,
|
||||
insecure = True
|
||||
)
|
||||
)
|
||||
)
|
||||
grpc_server_instrumentor = GrpcInstrumentorServer()
|
||||
grpc_server_instrumentor.instrument()
|
||||
|
||||
except (KeyError, DefaultCredentialsError):
|
||||
logger.info("Tracing disabled.")
|
||||
except Exception as e:
|
||||
logger.warn(f"Exception on Cloud Trace setup: {traceback.format_exc()}, tracing disabled.")
|
||||
|
||||
start(dummy_mode = True)
|
||||
21
src/emailservice/genproto.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash -eu
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# [START gke_emailservice_genproto]
|
||||
|
||||
python -m grpc_tools.protoc -I../../protos --python_out=. --grpc_python_out=. ../../protos/demo.proto
|
||||
|
||||
# [END gke_emailservice_genproto]
|
||||
41
src/emailservice/logger.py
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/python
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
# TODO(yoshifumi) this class is duplicated since other Python services are
|
||||
# not sharing the modules for logging.
|
||||
class CustomJsonFormatter(jsonlogger.JsonFormatter):
|
||||
def add_fields(self, log_record, record, message_dict):
|
||||
super(CustomJsonFormatter, self).add_fields(log_record, record, message_dict)
|
||||
if not log_record.get('timestamp'):
|
||||
log_record['timestamp'] = record.created
|
||||
if log_record.get('severity'):
|
||||
log_record['severity'] = log_record['severity'].upper()
|
||||
else:
|
||||
log_record['severity'] = record.levelname
|
||||
|
||||
def getJSONLogger(name):
|
||||
logger = logging.getLogger(name)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
formatter = CustomJsonFormatter('%(timestamp)s %(severity)s %(name)s %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
10
src/emailservice/requirements.in
Normal file
@@ -0,0 +1,10 @@
|
||||
google-api-core==2.28.1
|
||||
grpcio-health-checking==1.76.0
|
||||
grpcio==1.76.0
|
||||
jinja2==3.1.6
|
||||
python-json-logger==4.0.0
|
||||
google-cloud-trace==1.17.0
|
||||
requests==2.32.5
|
||||
opentelemetry-distro==0.60b1
|
||||
opentelemetry-instrumentation-grpc==0.60b1
|
||||
opentelemetry-exporter-otlp-proto-grpc==1.39.1
|
||||
120
src/emailservice/requirements.txt
Normal file
@@ -0,0 +1,120 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile requirements.in -o requirements.txt
|
||||
cachetools==5.3.2
|
||||
# via google-auth
|
||||
certifi==2024.7.4
|
||||
# via requests
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
google-api-core[grpc]==2.28.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# google-cloud-trace
|
||||
google-auth==2.23.4
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-trace
|
||||
google-cloud-trace==1.17.0
|
||||
# via -r requirements.in
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
grpcio==1.76.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# google-api-core
|
||||
# google-cloud-trace
|
||||
# grpcio-health-checking
|
||||
# grpcio-status
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
grpcio-health-checking==1.76.0
|
||||
# via -r requirements.in
|
||||
grpcio-status==1.76.0
|
||||
# via google-api-core
|
||||
idna==3.7
|
||||
# via requests
|
||||
importlib-metadata==6.8.0
|
||||
# via opentelemetry-api
|
||||
jinja2==3.1.6
|
||||
# via -r requirements.in
|
||||
markupsafe==2.1.3
|
||||
# via jinja2
|
||||
opentelemetry-api==1.39.1
|
||||
# via
|
||||
# opentelemetry-distro
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-instrumentation-grpc
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
opentelemetry-distro==0.60b1
|
||||
# via -r requirements.in
|
||||
opentelemetry-exporter-otlp-proto-common==1.39.1
|
||||
# via opentelemetry-exporter-otlp-proto-grpc
|
||||
opentelemetry-exporter-otlp-proto-grpc==1.39.1
|
||||
# via -r requirements.in
|
||||
opentelemetry-instrumentation==0.60b1
|
||||
# via
|
||||
# opentelemetry-distro
|
||||
# opentelemetry-instrumentation-grpc
|
||||
opentelemetry-instrumentation-grpc==0.60b1
|
||||
# via -r requirements.in
|
||||
opentelemetry-proto==1.39.1
|
||||
# via
|
||||
# opentelemetry-exporter-otlp-proto-common
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
opentelemetry-sdk==1.39.1
|
||||
# via
|
||||
# opentelemetry-distro
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
opentelemetry-semantic-conventions==0.60b1
|
||||
# via
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-instrumentation-grpc
|
||||
# opentelemetry-sdk
|
||||
packaging==25.0
|
||||
# via opentelemetry-instrumentation
|
||||
proto-plus==1.27.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-trace
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-trace
|
||||
# googleapis-common-protos
|
||||
# grpcio-health-checking
|
||||
# grpcio-status
|
||||
# opentelemetry-proto
|
||||
# proto-plus
|
||||
pyasn1==0.5.0
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
pyasn1-modules==0.3.0
|
||||
# via google-auth
|
||||
python-json-logger==4.0.0
|
||||
# via -r requirements.in
|
||||
requests==2.32.5
|
||||
# via
|
||||
# -r requirements.in
|
||||
# google-api-core
|
||||
rsa==4.9
|
||||
# via google-auth
|
||||
typing-extensions==4.15.0
|
||||
# via
|
||||
# grpcio
|
||||
# opentelemetry-api
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
urllib3==2.6.3
|
||||
# via requests
|
||||
wrapt==1.16.0
|
||||
# via
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-instrumentation-grpc
|
||||
zipp==3.19.1
|
||||
# via importlib-metadata
|
||||
53
src/emailservice/templates/confirmation.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Copyright 2020 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Your Order Confirmation</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<style>
|
||||
body{
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h2>Your Order Confirmation</h2>
|
||||
<p>Thanks for shopping with us!<p>
|
||||
<h3>Order ID</h3>
|
||||
<p>#{{ order.order_id }}</p>
|
||||
<h3>Shipping</h3>
|
||||
<p>#{{ order.shipping_tracking_id }}</p>
|
||||
<p>{{ order.shipping_cost.units }}. {{ "%02d" | format(order.shipping_cost.nanos // 10000000) }} {{ order.shipping_cost.currency_code }}</p>
|
||||
<p>{{ order.shipping_address.street_address_1 }}, {{order.shipping_address.street_address_2}}, {{order.shipping_address.city}}, {{order.shipping_address.country}} {{order.shipping_address.zip_code}}</p>
|
||||
<h3>Items</h3>
|
||||
<table style="width:100%">
|
||||
<tr>
|
||||
<th>Item No.</th>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
{% for item in order.items %}
|
||||
<tr>
|
||||
<td>#{{ item.item.product_id }}</td>
|
||||
<td>{{ item.item.quantity }}</td>
|
||||
<td>{{ item.cost.units }}.{{ "%02d" | format(item.cost.nanos // 10000000) }} {{ item.cost.currency_code }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
1
src/frontend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
vendor/
|
||||
0
src/frontend/.gitkeep
Normal file
44
src/frontend/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Copyright 2020 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Define a default value so it's not empty if the builder fails to provide it
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
WORKDIR /src
|
||||
|
||||
# restore dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Skaffold passes in debug-oriented compiler flags
|
||||
ARG SKAFFOLD_GO_GCFLAGS
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -ldflags="-s -w" -gcflags="${SKAFFOLD_GO_GCFLAGS}" -o /go/bin/frontend .
|
||||
|
||||
FROM gcr.io/distroless/static
|
||||
WORKDIR /src
|
||||
COPY --from=builder /go/bin/frontend /src/server
|
||||
COPY ./templates ./templates
|
||||
COPY ./static ./static
|
||||
|
||||
# Definition of this variable is used by 'skaffold debug' to identify a golang binary.
|
||||
# Default behavior - a failure prints a stack trace for the current goroutine.
|
||||
# See https://golang.org/pkg/runtime/
|
||||
ENV GOTRACEBACK=single
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/src/server"]
|
||||
5
src/frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# frontend
|
||||
|
||||
Run the following command to restore dependencies to `vendor/` directory:
|
||||
|
||||
dep ensure --vendor-only
|
||||
64
src/frontend/deployment_details.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var deploymentDetailsMap map[string]string
|
||||
var log *logrus.Logger
|
||||
|
||||
func init() {
|
||||
initializeLogger()
|
||||
// Use a goroutine to ensure loadDeploymentDetails()'s GCP API
|
||||
// calls don't block non-GCP deployments. See issue #685.
|
||||
go loadDeploymentDetails()
|
||||
}
|
||||
|
||||
func initializeLogger() {
|
||||
log = logrus.New()
|
||||
log.Level = logrus.DebugLevel
|
||||
log.Formatter = &logrus.JSONFormatter{
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyTime: "timestamp",
|
||||
logrus.FieldKeyLevel: "severity",
|
||||
logrus.FieldKeyMsg: "message",
|
||||
},
|
||||
TimestampFormat: time.RFC3339Nano,
|
||||
}
|
||||
log.Out = os.Stdout
|
||||
}
|
||||
|
||||
func loadDeploymentDetails() {
|
||||
deploymentDetailsMap = make(map[string]string)
|
||||
var metaServerClient = metadata.NewClient(&http.Client{})
|
||||
|
||||
podHostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Error("Failed to fetch the hostname for the Pod", err)
|
||||
}
|
||||
|
||||
podCluster, err := metaServerClient.InstanceAttributeValue("cluster-name")
|
||||
if err != nil {
|
||||
log.Error("Failed to fetch the name of the cluster in which the pod is running", err)
|
||||
}
|
||||
|
||||
podZone, err := metaServerClient.Zone()
|
||||
if err != nil {
|
||||
log.Error("Failed to fetch the Zone of the node where the pod is scheduled", err)
|
||||
}
|
||||
|
||||
deploymentDetailsMap["HOSTNAME"] = podHostname
|
||||
deploymentDetailsMap["CLUSTERNAME"] = podCluster
|
||||
deploymentDetailsMap["ZONE"] = podZone
|
||||
|
||||
log.WithFields(logrus.Fields{
|
||||
"cluster": podCluster,
|
||||
"zone": podZone,
|
||||
"hostname": podHostname,
|
||||
}).Debug("Loaded deployment details")
|
||||
}
|
||||
25
src/frontend/genproto.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash -eu
|
||||
#
|
||||
# Copyright 2018 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# [START gke_frontend_genproto]
|
||||
|
||||
PATH=$PATH:$(go env GOPATH)/bin
|
||||
protodir=../../protos
|
||||
outdir=./genproto
|
||||
|
||||
protoc --proto_path=$protodir --go_out=./$outdir --go_opt=paths=source_relative --go-grpc_out=./$outdir --go-grpc_opt=paths=source_relative $protodir/demo.proto
|
||||
|
||||
# [END gke_frontend_genproto]
|
||||
2610
src/frontend/genproto/demo.pb.go
Normal file
1179
src/frontend/genproto/demo_grpc.pb.go
Normal file
58
src/frontend/go.mod
Normal file
@@ -0,0 +1,58 @@
|
||||
module github.com/GoogleCloudPlatform/microservices-demo/src/frontend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.1
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0
|
||||
cloud.google.com/go/profiler v0.4.3
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.256.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
)
|
||||
146
src/frontend/go.sum
Normal file
@@ -0,0 +1,146 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/profiler v0.4.3 h1:IY3QNKlr8VbXwGWHcZbJQsMA/83ZTH6uAHf8jYyj7OI=
|
||||
cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0=
|
||||
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
||||
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846 h1:dDbsTLIK7EzwUq36kCSAsk0slouq/S0tWHeeGi97cD8=
|
||||
google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846/go.mod h1:PP0g88Dz3C7hRAfbQCQggeWAXjuqGsNPLE4s7jh0RGU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
635
src/frontend/handlers.go
Normal file
@@ -0,0 +1,635 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/frontend/genproto"
|
||||
"github.com/GoogleCloudPlatform/microservices-demo/src/frontend/money"
|
||||
"github.com/GoogleCloudPlatform/microservices-demo/src/frontend/validator"
|
||||
)
|
||||
|
||||
type platformDetails struct {
|
||||
css string
|
||||
provider string
|
||||
}
|
||||
|
||||
var (
|
||||
frontendMessage = strings.TrimSpace(os.Getenv("FRONTEND_MESSAGE"))
|
||||
isCymbalBrand = "true" == strings.ToLower(os.Getenv("CYMBAL_BRANDING"))
|
||||
assistantEnabled = "true" == strings.ToLower(os.Getenv("ENABLE_ASSISTANT"))
|
||||
templates = template.Must(template.New("").
|
||||
Funcs(template.FuncMap{
|
||||
"renderMoney": renderMoney,
|
||||
"renderCurrencyLogo": renderCurrencyLogo,
|
||||
}).ParseGlob("templates/*.html"))
|
||||
plat platformDetails
|
||||
)
|
||||
|
||||
var validEnvs = []string{"local", "gcp", "azure", "aws", "onprem", "alibaba"}
|
||||
|
||||
func (fe *frontendServer) homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
log.WithField("currency", currentCurrency(r)).Info("home")
|
||||
currencies, err := fe.getCurrencies(r.Context())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve currencies"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
products, err := fe.getProducts(r.Context())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve products"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cart, err := fe.getCart(r.Context(), sessionID(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve cart"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type productView struct {
|
||||
Item *pb.Product
|
||||
Price *pb.Money
|
||||
}
|
||||
ps := make([]productView, len(products))
|
||||
for i, p := range products {
|
||||
price, err := fe.convertCurrency(r.Context(), p.GetPriceUsd(), currentCurrency(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrapf(err, "failed to do currency conversion for product %s", p.GetId()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ps[i] = productView{p, price}
|
||||
}
|
||||
|
||||
// Set ENV_PLATFORM (default to local if not set; use env var if set; otherwise detect GCP, which overrides env)_
|
||||
var env = os.Getenv("ENV_PLATFORM")
|
||||
// Only override from env variable if set + valid env
|
||||
if env == "" || stringinSlice(validEnvs, env) == false {
|
||||
fmt.Println("env platform is either empty or invalid")
|
||||
env = "local"
|
||||
}
|
||||
// Autodetect GCP
|
||||
addrs, err := net.LookupHost("metadata.google.internal.")
|
||||
if err == nil && len(addrs) >= 0 {
|
||||
log.Debugf("Detected Google metadata server: %v, setting ENV_PLATFORM to GCP.", addrs)
|
||||
env = "gcp"
|
||||
}
|
||||
|
||||
log.Debugf("ENV_PLATFORM is: %s", env)
|
||||
plat = platformDetails{}
|
||||
plat.setPlatformDetails(strings.ToLower(env))
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "home", injectCommonTemplateData(r, map[string]interface{}{
|
||||
"show_currency": true,
|
||||
"currencies": currencies,
|
||||
"products": ps,
|
||||
"cart_size": cartSize(cart),
|
||||
"banner_color": os.Getenv("BANNER_COLOR"), // illustrates canary deployments
|
||||
"ad": fe.chooseAd(r.Context(), []string{}, log),
|
||||
})); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (plat *platformDetails) setPlatformDetails(env string) {
|
||||
if env == "aws" {
|
||||
plat.provider = "AWS"
|
||||
plat.css = "aws-platform"
|
||||
} else if env == "onprem" {
|
||||
plat.provider = "On-Premises"
|
||||
plat.css = "onprem-platform"
|
||||
} else if env == "azure" {
|
||||
plat.provider = "Azure"
|
||||
plat.css = "azure-platform"
|
||||
} else if env == "gcp" {
|
||||
plat.provider = "Google Cloud"
|
||||
plat.css = "gcp-platform"
|
||||
} else if env == "alibaba" {
|
||||
plat.provider = "Alibaba Cloud"
|
||||
plat.css = "alibaba-platform"
|
||||
} else {
|
||||
plat.provider = "local"
|
||||
plat.css = "local"
|
||||
}
|
||||
}
|
||||
|
||||
func (fe *frontendServer) productHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
renderHTTPError(log, r, w, errors.New("product id not specified"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.WithField("id", id).WithField("currency", currentCurrency(r)).
|
||||
Debug("serving product page")
|
||||
|
||||
p, err := fe.getProduct(r.Context(), id)
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve product"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
currencies, err := fe.getCurrencies(r.Context())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve currencies"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := fe.getCart(r.Context(), sessionID(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve cart"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
price, err := fe.convertCurrency(r.Context(), p.GetPriceUsd(), currentCurrency(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to convert currency"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// ignores the error retrieving recommendations since it is not critical
|
||||
recommendations, err := fe.getRecommendations(r.Context(), sessionID(r), []string{id})
|
||||
if err != nil {
|
||||
log.WithField("error", err).Warn("failed to get product recommendations")
|
||||
}
|
||||
|
||||
product := struct {
|
||||
Item *pb.Product
|
||||
Price *pb.Money
|
||||
}{p, price}
|
||||
|
||||
// Fetch packaging info (weight/dimensions) of the product
|
||||
// The packaging service is an optional microservice you can run as part of a Google Cloud demo.
|
||||
var packagingInfo *PackagingInfo = nil
|
||||
if isPackagingServiceConfigured() {
|
||||
packagingInfo, err = httpGetPackagingInfo(id)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to obtain product's packaging info:", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "product", injectCommonTemplateData(r, map[string]interface{}{
|
||||
"ad": fe.chooseAd(r.Context(), p.Categories, log),
|
||||
"show_currency": true,
|
||||
"currencies": currencies,
|
||||
"product": product,
|
||||
"recommendations": recommendations,
|
||||
"cart_size": cartSize(cart),
|
||||
"packagingInfo": packagingInfo,
|
||||
})); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (fe *frontendServer) addToCartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
quantity, _ := strconv.ParseUint(r.FormValue("quantity"), 10, 32)
|
||||
productID := r.FormValue("product_id")
|
||||
payload := validator.AddToCartPayload{
|
||||
Quantity: quantity,
|
||||
ProductID: productID,
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
renderHTTPError(log, r, w, validator.ValidationErrorResponse(err), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
log.WithField("product", payload.ProductID).WithField("quantity", payload.Quantity).Debug("adding to cart")
|
||||
|
||||
p, err := fe.getProduct(r.Context(), payload.ProductID)
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve product"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := fe.insertCart(r.Context(), sessionID(r), p.GetId(), int32(payload.Quantity)); err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to add to cart"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("location", baseUrl + "/cart")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
func (fe *frontendServer) emptyCartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
log.Debug("emptying cart")
|
||||
|
||||
if err := fe.emptyCart(r.Context(), sessionID(r)); err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to empty cart"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("location", baseUrl + "/")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
func (fe *frontendServer) viewCartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
log.Debug("view user cart")
|
||||
currencies, err := fe.getCurrencies(r.Context())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve currencies"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cart, err := fe.getCart(r.Context(), sessionID(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve cart"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// ignores the error retrieving recommendations since it is not critical
|
||||
recommendations, err := fe.getRecommendations(r.Context(), sessionID(r), cartIDs(cart))
|
||||
if err != nil {
|
||||
log.WithField("error", err).Warn("failed to get product recommendations")
|
||||
}
|
||||
|
||||
shippingCost, err := fe.getShippingQuote(r.Context(), cart, currentCurrency(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to get shipping quote"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type cartItemView struct {
|
||||
Item *pb.Product
|
||||
Quantity int32
|
||||
Price *pb.Money
|
||||
}
|
||||
items := make([]cartItemView, len(cart))
|
||||
totalPrice := pb.Money{CurrencyCode: currentCurrency(r)}
|
||||
for i, item := range cart {
|
||||
p, err := fe.getProduct(r.Context(), item.GetProductId())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrapf(err, "could not retrieve product #%s", item.GetProductId()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
price, err := fe.convertCurrency(r.Context(), p.GetPriceUsd(), currentCurrency(r))
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrapf(err, "could not convert currency for product #%s", item.GetProductId()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
multPrice := money.MultiplySlow(*price, uint32(item.GetQuantity()))
|
||||
items[i] = cartItemView{
|
||||
Item: p,
|
||||
Quantity: item.GetQuantity(),
|
||||
Price: &multPrice}
|
||||
totalPrice = money.Must(money.Sum(totalPrice, multPrice))
|
||||
}
|
||||
totalPrice = money.Must(money.Sum(totalPrice, *shippingCost))
|
||||
year := time.Now().Year()
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "cart", injectCommonTemplateData(r, map[string]interface{}{
|
||||
"currencies": currencies,
|
||||
"recommendations": recommendations,
|
||||
"cart_size": cartSize(cart),
|
||||
"shipping_cost": shippingCost,
|
||||
"show_currency": true,
|
||||
"total_cost": totalPrice,
|
||||
"items": items,
|
||||
"expiration_years": []int{year, year + 1, year + 2, year + 3, year + 4},
|
||||
})); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (fe *frontendServer) placeOrderHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
log.Debug("placing order")
|
||||
|
||||
var (
|
||||
email = r.FormValue("email")
|
||||
streetAddress = r.FormValue("street_address")
|
||||
zipCode, _ = strconv.ParseInt(r.FormValue("zip_code"), 10, 32)
|
||||
city = r.FormValue("city")
|
||||
state = r.FormValue("state")
|
||||
country = r.FormValue("country")
|
||||
ccNumber = r.FormValue("credit_card_number")
|
||||
ccMonth, _ = strconv.ParseInt(r.FormValue("credit_card_expiration_month"), 10, 32)
|
||||
ccYear, _ = strconv.ParseInt(r.FormValue("credit_card_expiration_year"), 10, 32)
|
||||
ccCVV, _ = strconv.ParseInt(r.FormValue("credit_card_cvv"), 10, 32)
|
||||
)
|
||||
|
||||
payload := validator.PlaceOrderPayload{
|
||||
Email: email,
|
||||
StreetAddress: streetAddress,
|
||||
ZipCode: zipCode,
|
||||
City: city,
|
||||
State: state,
|
||||
Country: country,
|
||||
CcNumber: ccNumber,
|
||||
CcMonth: ccMonth,
|
||||
CcYear: ccYear,
|
||||
CcCVV: ccCVV,
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
renderHTTPError(log, r, w, validator.ValidationErrorResponse(err), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := pb.NewCheckoutServiceClient(fe.checkoutSvcConn).
|
||||
PlaceOrder(r.Context(), &pb.PlaceOrderRequest{
|
||||
Email: payload.Email,
|
||||
CreditCard: &pb.CreditCardInfo{
|
||||
CreditCardNumber: payload.CcNumber,
|
||||
CreditCardExpirationMonth: int32(payload.CcMonth),
|
||||
CreditCardExpirationYear: int32(payload.CcYear),
|
||||
CreditCardCvv: int32(payload.CcCVV)},
|
||||
UserId: sessionID(r),
|
||||
UserCurrency: currentCurrency(r),
|
||||
Address: &pb.Address{
|
||||
StreetAddress: payload.StreetAddress,
|
||||
City: payload.City,
|
||||
State: payload.State,
|
||||
ZipCode: int32(payload.ZipCode),
|
||||
Country: payload.Country},
|
||||
})
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to complete the order"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.WithField("order", order.GetOrder().GetOrderId()).Info("order placed")
|
||||
|
||||
order.GetOrder().GetItems()
|
||||
recommendations, _ := fe.getRecommendations(r.Context(), sessionID(r), nil)
|
||||
|
||||
totalPaid := *order.GetOrder().GetShippingCost()
|
||||
for _, v := range order.GetOrder().GetItems() {
|
||||
multPrice := money.MultiplySlow(*v.GetCost(), uint32(v.GetItem().GetQuantity()))
|
||||
totalPaid = money.Must(money.Sum(totalPaid, multPrice))
|
||||
}
|
||||
|
||||
currencies, err := fe.getCurrencies(r.Context())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve currencies"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "order", injectCommonTemplateData(r, map[string]interface{}{
|
||||
"show_currency": false,
|
||||
"currencies": currencies,
|
||||
"order": order.GetOrder(),
|
||||
"total_paid": &totalPaid,
|
||||
"recommendations": recommendations,
|
||||
})); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (fe *frontendServer) assistantHandler(w http.ResponseWriter, r *http.Request) {
|
||||
currencies, err := fe.getCurrencies(r.Context())
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "could not retrieve currencies"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "assistant", injectCommonTemplateData(r, map[string]interface{}{
|
||||
"show_currency": false,
|
||||
"currencies": currencies,
|
||||
})); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (fe *frontendServer) logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
log.Debug("logging out")
|
||||
for _, c := range r.Cookies() {
|
||||
c.Expires = time.Now().Add(-time.Hour * 24 * 365)
|
||||
c.MaxAge = -1
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
w.Header().Set("Location", baseUrl + "/")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getProductByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["ids"]
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
p, err := fe.getProduct(r.Context(), id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (fe *frontendServer) chatBotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
type Response struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LLMResponse struct {
|
||||
Content string `json:"content"`
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
|
||||
var response LLMResponse
|
||||
|
||||
url := "http://" + fe.shoppingAssistantSvcAddr
|
||||
req, err := http.NewRequest(http.MethodPost, url, r.Body)
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to create request"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to send request"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to read response"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%+v\n", body)
|
||||
fmt.Printf("%+v\n", res)
|
||||
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
renderHTTPError(log, r, w, errors.Wrap(err, "failed to unmarshal body"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// respond with the same message
|
||||
json.NewEncoder(w).Encode(Response{Message: response.Content})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (fe *frontendServer) setCurrencyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := r.Context().Value(ctxKeyLog{}).(logrus.FieldLogger)
|
||||
cur := r.FormValue("currency_code")
|
||||
payload := validator.SetCurrencyPayload{Currency: cur}
|
||||
if err := payload.Validate(); err != nil {
|
||||
renderHTTPError(log, r, w, validator.ValidationErrorResponse(err), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
log.WithField("curr.new", payload.Currency).WithField("curr.old", currentCurrency(r)).
|
||||
Debug("setting currency")
|
||||
|
||||
if payload.Currency != "" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieCurrency,
|
||||
Value: payload.Currency,
|
||||
MaxAge: cookieMaxAge,
|
||||
})
|
||||
}
|
||||
referer := r.Header.Get("referer")
|
||||
if referer == "" {
|
||||
referer = baseUrl + "/"
|
||||
}
|
||||
w.Header().Set("Location", referer)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
// chooseAd queries for advertisements available and randomly chooses one, if
|
||||
// available. It ignores the error retrieving the ad since it is not critical.
|
||||
func (fe *frontendServer) chooseAd(ctx context.Context, ctxKeys []string, log logrus.FieldLogger) *pb.Ad {
|
||||
ads, err := fe.getAd(ctx, ctxKeys)
|
||||
if err != nil {
|
||||
log.WithField("error", err).Warn("failed to retrieve ads")
|
||||
return nil
|
||||
}
|
||||
return ads[rand.Intn(len(ads))]
|
||||
}
|
||||
|
||||
func renderHTTPError(log logrus.FieldLogger, r *http.Request, w http.ResponseWriter, err error, code int) {
|
||||
log.WithField("error", err).Error("request error")
|
||||
errMsg := fmt.Sprintf("%+v", err)
|
||||
|
||||
w.WriteHeader(code)
|
||||
|
||||
if templateErr := templates.ExecuteTemplate(w, "error", injectCommonTemplateData(r, map[string]interface{}{
|
||||
"error": errMsg,
|
||||
"status_code": code,
|
||||
"status": http.StatusText(code),
|
||||
})); templateErr != nil {
|
||||
log.Println(templateErr)
|
||||
}
|
||||
}
|
||||
|
||||
func injectCommonTemplateData(r *http.Request, payload map[string]interface{}) map[string]interface{} {
|
||||
data := map[string]interface{}{
|
||||
"session_id": sessionID(r),
|
||||
"request_id": r.Context().Value(ctxKeyRequestID{}),
|
||||
"user_currency": currentCurrency(r),
|
||||
"platform_css": plat.css,
|
||||
"platform_name": plat.provider,
|
||||
"is_cymbal_brand": isCymbalBrand,
|
||||
"assistant_enabled": assistantEnabled,
|
||||
"deploymentDetails": deploymentDetailsMap,
|
||||
"frontendMessage": frontendMessage,
|
||||
"currentYear": time.Now().Year(),
|
||||
"baseUrl": baseUrl,
|
||||
}
|
||||
|
||||
for k, v := range payload {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func currentCurrency(r *http.Request) string {
|
||||
c, _ := r.Cookie(cookieCurrency)
|
||||
if c != nil {
|
||||
return c.Value
|
||||
}
|
||||
return defaultCurrency
|
||||
}
|
||||
|
||||
func sessionID(r *http.Request) string {
|
||||
v := r.Context().Value(ctxKeySessionID{})
|
||||
if v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cartIDs(c []*pb.CartItem) []string {
|
||||
out := make([]string, len(c))
|
||||
for i, v := range c {
|
||||
out[i] = v.GetProductId()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// get total # of items in cart
|
||||
func cartSize(c []*pb.CartItem) int {
|
||||
cartSize := 0
|
||||
for _, item := range c {
|
||||
cartSize += int(item.GetQuantity())
|
||||
}
|
||||
return cartSize
|
||||
}
|
||||
|
||||
func renderMoney(money pb.Money) string {
|
||||
currencyLogo := renderCurrencyLogo(money.GetCurrencyCode())
|
||||
return fmt.Sprintf("%s%d.%02d", currencyLogo, money.GetUnits(), money.GetNanos()/10000000)
|
||||
}
|
||||
|
||||
func renderCurrencyLogo(currencyCode string) string {
|
||||
logos := map[string]string{
|
||||
"USD": "$",
|
||||
"CAD": "$",
|
||||
"JPY": "¥",
|
||||
"EUR": "€",
|
||||
"TRY": "₺",
|
||||
"GBP": "£",
|
||||
}
|
||||
|
||||
logo := "$" //default
|
||||
if val, ok := logos[currencyCode]; ok {
|
||||
logo = val
|
||||
}
|
||||
return logo
|
||||
}
|
||||
|
||||
func stringinSlice(slice []string, val string) bool {
|
||||
for _, item := range slice {
|
||||
if item == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
235
src/frontend/main.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/profiler"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
port = "8080"
|
||||
defaultCurrency = "USD"
|
||||
cookieMaxAge = 60 * 60 * 48
|
||||
|
||||
cookiePrefix = "shop_"
|
||||
cookieSessionID = cookiePrefix + "session-id"
|
||||
cookieCurrency = cookiePrefix + "currency"
|
||||
)
|
||||
|
||||
var (
|
||||
whitelistedCurrencies = map[string]bool{
|
||||
"USD": true,
|
||||
"EUR": true,
|
||||
"CAD": true,
|
||||
"JPY": true,
|
||||
"GBP": true,
|
||||
"TRY": true,
|
||||
}
|
||||
|
||||
baseUrl = ""
|
||||
)
|
||||
|
||||
type ctxKeySessionID struct{}
|
||||
|
||||
type frontendServer struct {
|
||||
productCatalogSvcAddr string
|
||||
productCatalogSvcConn *grpc.ClientConn
|
||||
|
||||
currencySvcAddr string
|
||||
currencySvcConn *grpc.ClientConn
|
||||
|
||||
cartSvcAddr string
|
||||
cartSvcConn *grpc.ClientConn
|
||||
|
||||
recommendationSvcAddr string
|
||||
recommendationSvcConn *grpc.ClientConn
|
||||
|
||||
checkoutSvcAddr string
|
||||
checkoutSvcConn *grpc.ClientConn
|
||||
|
||||
shippingSvcAddr string
|
||||
shippingSvcConn *grpc.ClientConn
|
||||
|
||||
adSvcAddr string
|
||||
adSvcConn *grpc.ClientConn
|
||||
|
||||
collectorAddr string
|
||||
collectorConn *grpc.ClientConn
|
||||
|
||||
shoppingAssistantSvcAddr string
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
log := logrus.New()
|
||||
log.Level = logrus.DebugLevel
|
||||
log.Formatter = &logrus.JSONFormatter{
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyTime: "timestamp",
|
||||
logrus.FieldKeyLevel: "severity",
|
||||
logrus.FieldKeyMsg: "message",
|
||||
},
|
||||
TimestampFormat: time.RFC3339Nano,
|
||||
}
|
||||
log.Out = os.Stdout
|
||||
|
||||
svc := new(frontendServer)
|
||||
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{}, propagation.Baggage{}))
|
||||
|
||||
baseUrl = os.Getenv("BASE_URL")
|
||||
|
||||
if os.Getenv("ENABLE_TRACING") == "1" {
|
||||
log.Info("Tracing enabled.")
|
||||
initTracing(log, ctx, svc)
|
||||
} else {
|
||||
log.Info("Tracing disabled.")
|
||||
}
|
||||
|
||||
if os.Getenv("ENABLE_PROFILER") == "1" {
|
||||
log.Info("Profiling enabled.")
|
||||
go initProfiling(log, "frontend", "1.0.0")
|
||||
} else {
|
||||
log.Info("Profiling disabled.")
|
||||
}
|
||||
|
||||
srvPort := port
|
||||
if os.Getenv("PORT") != "" {
|
||||
srvPort = os.Getenv("PORT")
|
||||
}
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
mustMapEnv(&svc.productCatalogSvcAddr, "PRODUCT_CATALOG_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.currencySvcAddr, "CURRENCY_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.cartSvcAddr, "CART_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.recommendationSvcAddr, "RECOMMENDATION_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.checkoutSvcAddr, "CHECKOUT_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.shippingSvcAddr, "SHIPPING_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.adSvcAddr, "AD_SERVICE_ADDR")
|
||||
mustMapEnv(&svc.shoppingAssistantSvcAddr, "SHOPPING_ASSISTANT_SERVICE_ADDR")
|
||||
|
||||
mustConnGRPC(ctx, &svc.currencySvcConn, svc.currencySvcAddr)
|
||||
mustConnGRPC(ctx, &svc.productCatalogSvcConn, svc.productCatalogSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.cartSvcConn, svc.cartSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.recommendationSvcConn, svc.recommendationSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.shippingSvcConn, svc.shippingSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.checkoutSvcConn, svc.checkoutSvcAddr)
|
||||
mustConnGRPC(ctx, &svc.adSvcConn, svc.adSvcAddr)
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc(baseUrl+"/", svc.homeHandler).Methods(http.MethodGet, http.MethodHead)
|
||||
r.HandleFunc(baseUrl+"/product/{id}", svc.productHandler).Methods(http.MethodGet, http.MethodHead)
|
||||
r.HandleFunc(baseUrl+"/cart", svc.viewCartHandler).Methods(http.MethodGet, http.MethodHead)
|
||||
r.HandleFunc(baseUrl+"/cart", svc.addToCartHandler).Methods(http.MethodPost)
|
||||
r.HandleFunc(baseUrl+"/cart/empty", svc.emptyCartHandler).Methods(http.MethodPost)
|
||||
r.HandleFunc(baseUrl+"/setCurrency", svc.setCurrencyHandler).Methods(http.MethodPost)
|
||||
r.HandleFunc(baseUrl+"/logout", svc.logoutHandler).Methods(http.MethodGet)
|
||||
r.HandleFunc(baseUrl+"/cart/checkout", svc.placeOrderHandler).Methods(http.MethodPost)
|
||||
r.HandleFunc(baseUrl+"/assistant", svc.assistantHandler).Methods(http.MethodGet)
|
||||
r.PathPrefix(baseUrl + "/static/").Handler(http.StripPrefix(baseUrl+"/static/", http.FileServer(http.Dir("./static/"))))
|
||||
r.HandleFunc(baseUrl+"/robots.txt", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "User-agent: *\nDisallow: /") })
|
||||
r.HandleFunc(baseUrl+"/_healthz", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "ok") })
|
||||
r.HandleFunc(baseUrl+"/product-meta/{ids}", svc.getProductByID).Methods(http.MethodGet)
|
||||
r.HandleFunc(baseUrl+"/bot", svc.chatBotHandler).Methods(http.MethodPost)
|
||||
|
||||
var handler http.Handler = r
|
||||
handler = &logHandler{log: log, next: handler} // add logging
|
||||
handler = ensureSessionID(handler) // add session ID
|
||||
handler = otelhttp.NewHandler(handler, "frontend") // add OTel tracing
|
||||
|
||||
log.Infof("starting server on %s:%s", addr, srvPort)
|
||||
log.Fatal(http.ListenAndServe(addr+":"+srvPort, handler))
|
||||
}
|
||||
func initStats(log logrus.FieldLogger) {
|
||||
// TODO(arbrown) Implement OpenTelemtry stats
|
||||
}
|
||||
|
||||
func initTracing(log logrus.FieldLogger, ctx context.Context, svc *frontendServer) (*sdktrace.TracerProvider, error) {
|
||||
mustMapEnv(&svc.collectorAddr, "COLLECTOR_SERVICE_ADDR")
|
||||
mustConnGRPC(ctx, &svc.collectorConn, svc.collectorAddr)
|
||||
exporter, err := otlptracegrpc.New(
|
||||
ctx,
|
||||
otlptracegrpc.WithGRPCConn(svc.collectorConn))
|
||||
if err != nil {
|
||||
log.Warnf("warn: Failed to create trace exporter: %v", err)
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()))
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
return tp, err
|
||||
}
|
||||
|
||||
func initProfiling(log logrus.FieldLogger, service, version string) {
|
||||
// TODO(ahmetb) this method is duplicated in other microservices using Go
|
||||
// since they are not sharing packages.
|
||||
for i := 1; i <= 3; i++ {
|
||||
log = log.WithField("retry", i)
|
||||
if err := profiler.Start(profiler.Config{
|
||||
Service: service,
|
||||
ServiceVersion: version,
|
||||
// ProjectID must be set if not running on GCP.
|
||||
// ProjectID: "my-project",
|
||||
}); err != nil {
|
||||
log.Warnf("warn: failed to start profiler: %+v", err)
|
||||
} else {
|
||||
log.Info("started Stackdriver profiler")
|
||||
return
|
||||
}
|
||||
d := time.Second * 10 * time.Duration(i)
|
||||
log.Debugf("sleeping %v to retry initializing Stackdriver profiler", d)
|
||||
time.Sleep(d)
|
||||
}
|
||||
log.Warn("warning: could not initialize Stackdriver profiler after retrying, giving up")
|
||||
}
|
||||
|
||||
func mustMapEnv(target *string, envKey string) {
|
||||
v := os.Getenv(envKey)
|
||||
if v == "" {
|
||||
panic(fmt.Sprintf("environment variable %q not set", envKey))
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
|
||||
func mustConnGRPC(ctx context.Context, conn **grpc.ClientConn, addr string) {
|
||||
var err error
|
||||
_, cancel := context.WithTimeout(ctx, time.Second*3)
|
||||
defer cancel()
|
||||
*conn, err = grpc.NewClient(addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
|
||||
if err != nil {
|
||||
panic(errors.Wrapf(err, "grpc: failed to connect %s", addr))
|
||||
}
|
||||
}
|
||||
111
src/frontend/middleware.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type ctxKeyLog struct{}
|
||||
type ctxKeyRequestID struct{}
|
||||
|
||||
type logHandler struct {
|
||||
log *logrus.Logger
|
||||
next http.Handler
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
b int
|
||||
status int
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Header() http.Header { return r.w.Header() }
|
||||
|
||||
func (r *responseRecorder) Write(p []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
n, err := r.w.Write(p)
|
||||
r.b += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(statusCode int) {
|
||||
r.status = statusCode
|
||||
r.w.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (lh *logHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
requestID, _ := uuid.NewRandom()
|
||||
ctx = context.WithValue(ctx, ctxKeyRequestID{}, requestID.String())
|
||||
|
||||
start := time.Now()
|
||||
rr := &responseRecorder{w: w}
|
||||
log := lh.log.WithFields(logrus.Fields{
|
||||
"http.req.path": r.URL.Path,
|
||||
"http.req.method": r.Method,
|
||||
"http.req.id": requestID.String(),
|
||||
})
|
||||
if v, ok := r.Context().Value(ctxKeySessionID{}).(string); ok {
|
||||
log = log.WithField("session", v)
|
||||
}
|
||||
log.Debug("request started")
|
||||
defer func() {
|
||||
log.WithFields(logrus.Fields{
|
||||
"http.resp.took_ms": int64(time.Since(start) / time.Millisecond),
|
||||
"http.resp.status": rr.status,
|
||||
"http.resp.bytes": rr.b}).Debugf("request complete")
|
||||
}()
|
||||
|
||||
ctx = context.WithValue(ctx, ctxKeyLog{}, log)
|
||||
r = r.WithContext(ctx)
|
||||
lh.next.ServeHTTP(rr, r)
|
||||
}
|
||||
|
||||
func ensureSessionID(next http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var sessionID string
|
||||
c, err := r.Cookie(cookieSessionID)
|
||||
if err == http.ErrNoCookie {
|
||||
if os.Getenv("ENABLE_SINGLE_SHARED_SESSION") == "true" {
|
||||
// Hard coded user id, shared across sessions
|
||||
sessionID = "12345678-1234-1234-1234-123456789123"
|
||||
} else {
|
||||
u, _ := uuid.NewRandom()
|
||||
sessionID = u.String()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieSessionID,
|
||||
Value: sessionID,
|
||||
MaxAge: cookieMaxAge,
|
||||
})
|
||||
} else if err != nil {
|
||||
return
|
||||
} else {
|
||||
sessionID = c.Value
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKeySessionID{}, sessionID)
|
||||
r = r.WithContext(ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
132
src/frontend/money/money.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/frontend/genproto"
|
||||
)
|
||||
|
||||
const (
|
||||
nanosMin = -999999999
|
||||
nanosMax = +999999999
|
||||
nanosMod = 1000000000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidValue = errors.New("one of the specified money values is invalid")
|
||||
ErrMismatchingCurrency = errors.New("mismatching currency codes")
|
||||
)
|
||||
|
||||
// IsValid checks if specified value has a valid units/nanos signs and ranges.
|
||||
func IsValid(m pb.Money) bool {
|
||||
return signMatches(m) && validNanos(m.GetNanos())
|
||||
}
|
||||
|
||||
func signMatches(m pb.Money) bool {
|
||||
return m.GetNanos() == 0 || m.GetUnits() == 0 || (m.GetNanos() < 0) == (m.GetUnits() < 0)
|
||||
}
|
||||
|
||||
func validNanos(nanos int32) bool { return nanosMin <= nanos && nanos <= nanosMax }
|
||||
|
||||
// IsZero returns true if the specified money value is equal to zero.
|
||||
func IsZero(m pb.Money) bool { return m.GetUnits() == 0 && m.GetNanos() == 0 }
|
||||
|
||||
// IsPositive returns true if the specified money value is valid and is
|
||||
// positive.
|
||||
func IsPositive(m pb.Money) bool {
|
||||
return IsValid(m) && m.GetUnits() > 0 || (m.GetUnits() == 0 && m.GetNanos() > 0)
|
||||
}
|
||||
|
||||
// IsNegative returns true if the specified money value is valid and is
|
||||
// negative.
|
||||
func IsNegative(m pb.Money) bool {
|
||||
return IsValid(m) && m.GetUnits() < 0 || (m.GetUnits() == 0 && m.GetNanos() < 0)
|
||||
}
|
||||
|
||||
// AreSameCurrency returns true if values l and r have a currency code and
|
||||
// they are the same values.
|
||||
func AreSameCurrency(l, r pb.Money) bool {
|
||||
return l.GetCurrencyCode() == r.GetCurrencyCode() && l.GetCurrencyCode() != ""
|
||||
}
|
||||
|
||||
// AreEquals returns true if values l and r are the equal, including the
|
||||
// currency. This does not check validity of the provided values.
|
||||
func AreEquals(l, r pb.Money) bool {
|
||||
return l.GetCurrencyCode() == r.GetCurrencyCode() &&
|
||||
l.GetUnits() == r.GetUnits() && l.GetNanos() == r.GetNanos()
|
||||
}
|
||||
|
||||
// Negate returns the same amount with the sign negated.
|
||||
func Negate(m pb.Money) pb.Money {
|
||||
return pb.Money{
|
||||
Units: -m.GetUnits(),
|
||||
Nanos: -m.GetNanos(),
|
||||
CurrencyCode: m.GetCurrencyCode()}
|
||||
}
|
||||
|
||||
// Must panics if the given error is not nil. This can be used with other
|
||||
// functions like: "m := Must(Sum(a,b))".
|
||||
func Must(v pb.Money, err error) pb.Money {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Sum adds two values. Returns an error if one of the values are invalid or
|
||||
// currency codes are not matching (unless currency code is unspecified for
|
||||
// both).
|
||||
func Sum(l, r pb.Money) (pb.Money, error) {
|
||||
if !IsValid(l) || !IsValid(r) {
|
||||
return pb.Money{}, ErrInvalidValue
|
||||
} else if l.GetCurrencyCode() != r.GetCurrencyCode() {
|
||||
return pb.Money{}, ErrMismatchingCurrency
|
||||
}
|
||||
units := l.GetUnits() + r.GetUnits()
|
||||
nanos := l.GetNanos() + r.GetNanos()
|
||||
|
||||
if (units == 0 && nanos == 0) || (units > 0 && nanos >= 0) || (units < 0 && nanos <= 0) {
|
||||
// same sign <units, nanos>
|
||||
units += int64(nanos / nanosMod)
|
||||
nanos = nanos % nanosMod
|
||||
} else {
|
||||
// different sign. nanos guaranteed to not to go over the limit
|
||||
if units > 0 {
|
||||
units--
|
||||
nanos += nanosMod
|
||||
} else {
|
||||
units++
|
||||
nanos -= nanosMod
|
||||
}
|
||||
}
|
||||
|
||||
return pb.Money{
|
||||
Units: units,
|
||||
Nanos: nanos,
|
||||
CurrencyCode: l.GetCurrencyCode()}, nil
|
||||
}
|
||||
|
||||
// MultiplySlow is a slow multiplication operation done through adding the value
|
||||
// to itself n-1 times.
|
||||
func MultiplySlow(m pb.Money, n uint32) pb.Money {
|
||||
out := m
|
||||
for n > 1 {
|
||||
out = Must(Sum(out, m))
|
||||
n--
|
||||
}
|
||||
return out
|
||||
}
|
||||
245
src/frontend/money/money_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/frontend/genproto"
|
||||
)
|
||||
|
||||
func mmc(u int64, n int32, c string) pb.Money { return pb.Money{Units: u, Nanos: n, CurrencyCode: c} }
|
||||
func mm(u int64, n int32) pb.Money { return mmc(u, n, "") }
|
||||
|
||||
func TestIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"valid -/-", mm(-981273891273, -999999999), true},
|
||||
{"invalid -/+", mm(-981273891273, +999999999), false},
|
||||
{"valid +/+", mm(981273891273, 999999999), true},
|
||||
{"invalid +/-", mm(981273891273, -999999999), false},
|
||||
{"invalid +/+overflow", mm(3, 1000000000), false},
|
||||
{"invalid +/-overflow", mm(3, -1000000000), false},
|
||||
{"invalid -/+overflow", mm(-3, 1000000000), false},
|
||||
{"invalid -/-overflow", mm(-3, -1000000000), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValid(tt.in); got != tt.want {
|
||||
t.Errorf("IsValid(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZero(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"zero", mm(0, 0), true},
|
||||
{"not-zero (-/+)", mm(-1, +1), false},
|
||||
{"not-zero (-/-)", mm(-1, -1), false},
|
||||
{"not-zero (+/+)", mm(+1, +1), false},
|
||||
{"not-zero (+/-)", mm(+1, -1), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsZero(tt.in); got != tt.want {
|
||||
t.Errorf("IsZero(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPositive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"zero", mm(0, 0), false},
|
||||
{"positive (+/+)", mm(+1, +1), true},
|
||||
{"invalid (-/+)", mm(-1, +1), false},
|
||||
{"negative (-/-)", mm(-1, -1), false},
|
||||
{"invalid (+/-)", mm(+1, -1), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsPositive(tt.in); got != tt.want {
|
||||
t.Errorf("IsPositive(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNegative(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want bool
|
||||
}{
|
||||
{"zero", mm(0, 0), false},
|
||||
{"positive (+/+)", mm(+1, +1), false},
|
||||
{"invalid (-/+)", mm(-1, +1), false},
|
||||
{"negative (-/-)", mm(-1, -1), true},
|
||||
{"invalid (+/-)", mm(+1, -1), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsNegative(tt.in); got != tt.want {
|
||||
t.Errorf("IsNegative(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreSameCurrency(t *testing.T) {
|
||||
type args struct {
|
||||
l pb.Money
|
||||
r pb.Money
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{"both empty currency", args{mmc(1, 0, ""), mmc(2, 0, "")}, false},
|
||||
{"left empty currency", args{mmc(1, 0, ""), mmc(2, 0, "USD")}, false},
|
||||
{"right empty currency", args{mmc(1, 0, "USD"), mmc(2, 0, "")}, false},
|
||||
{"mismatching", args{mmc(1, 0, "USD"), mmc(2, 0, "CAD")}, false},
|
||||
{"matching", args{mmc(1, 0, "USD"), mmc(2, 0, "USD")}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AreSameCurrency(tt.args.l, tt.args.r); got != tt.want {
|
||||
t.Errorf("AreSameCurrency([%v],[%v]) = %v, want %v", tt.args.l, tt.args.r, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreEquals(t *testing.T) {
|
||||
type args struct {
|
||||
l pb.Money
|
||||
r pb.Money
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{"equals", args{mmc(1, 2, "USD"), mmc(1, 2, "USD")}, true},
|
||||
{"mismatching currency", args{mmc(1, 2, "USD"), mmc(1, 2, "CAD")}, false},
|
||||
{"mismatching units", args{mmc(10, 20, "USD"), mmc(1, 20, "USD")}, false},
|
||||
{"mismatching nanos", args{mmc(1, 2, "USD"), mmc(1, 20, "USD")}, false},
|
||||
{"negated", args{mmc(1, 2, "USD"), mmc(-1, -2, "USD")}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AreEquals(tt.args.l, tt.args.r); got != tt.want {
|
||||
t.Errorf("AreEquals([%v],[%v]) = %v, want %v", tt.args.l, tt.args.r, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in pb.Money
|
||||
want pb.Money
|
||||
}{
|
||||
{"zero", mm(0, 0), mm(0, 0)},
|
||||
{"negative", mm(-1, -200), mm(1, 200)},
|
||||
{"positive", mm(1, 200), mm(-1, -200)},
|
||||
{"carries currency code", mmc(0, 0, "XXX"), mmc(0, 0, "XXX")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := Negate(tt.in); !AreEquals(got, tt.want) {
|
||||
t.Errorf("Negate([%v]) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMust_pass(t *testing.T) {
|
||||
v := Must(mm(2, 3), nil)
|
||||
if !AreEquals(v, mm(2, 3)) {
|
||||
t.Errorf("returned the wrong value: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMust_panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf("panic captured: %v", r)
|
||||
}
|
||||
}()
|
||||
Must(mm(2, 3), fmt.Errorf("some error"))
|
||||
t.Fatal("this should not have executed due to the panic above")
|
||||
}
|
||||
|
||||
func TestSum(t *testing.T) {
|
||||
type args struct {
|
||||
l pb.Money
|
||||
r pb.Money
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want pb.Money
|
||||
wantErr error
|
||||
}{
|
||||
{"0+0=0", args{mm(0, 0), mm(0, 0)}, mm(0, 0), nil},
|
||||
{"Error: currency code on left", args{mmc(0, 0, "XXX"), mm(0, 0)}, mm(0, 0), ErrMismatchingCurrency},
|
||||
{"Error: currency code on right", args{mm(0, 0), mmc(0, 0, "YYY")}, mm(0, 0), ErrMismatchingCurrency},
|
||||
{"Error: currency code mismatch", args{mmc(0, 0, "AAA"), mmc(0, 0, "BBB")}, mm(0, 0), ErrMismatchingCurrency},
|
||||
{"Error: invalid +/-", args{mm(+1, -1), mm(0, 0)}, mm(0, 0), ErrInvalidValue},
|
||||
{"Error: invalid -/+", args{mm(0, 0), mm(-1, +2)}, mm(0, 0), ErrInvalidValue},
|
||||
{"Error: invalid nanos", args{mm(0, 1000000000), mm(1, 0)}, mm(0, 0), ErrInvalidValue},
|
||||
{"both positive (no carry)", args{mm(2, 200000000), mm(2, 200000000)}, mm(4, 400000000), nil},
|
||||
{"both positive (nanos=max)", args{mm(2, 111111111), mm(2, 888888888)}, mm(4, 999999999), nil},
|
||||
{"both positive (carry)", args{mm(2, 200000000), mm(2, 900000000)}, mm(5, 100000000), nil},
|
||||
{"both negative (no carry)", args{mm(-2, -200000000), mm(-2, -200000000)}, mm(-4, -400000000), nil},
|
||||
{"both negative (carry)", args{mm(-2, -200000000), mm(-2, -900000000)}, mm(-5, -100000000), nil},
|
||||
{"mixed (larger positive, just decimals)", args{mm(11, 0), mm(-2, 0)}, mm(9, 0), nil},
|
||||
{"mixed (larger negative, just decimals)", args{mm(-11, 0), mm(2, 0)}, mm(-9, 0), nil},
|
||||
{"mixed (larger positive, no borrow)", args{mm(11, 100000000), mm(-2, -100000000)}, mm(9, 0), nil},
|
||||
{"mixed (larger positive, with borrow)", args{mm(11, 100000000), mm(-2, -9000000 /*.09*/)}, mm(9, 91000000 /*.091*/), nil},
|
||||
{"mixed (larger negative, no borrow)", args{mm(-11, -100000000), mm(2, 100000000)}, mm(-9, 0), nil},
|
||||
{"mixed (larger negative, with borrow)", args{mm(-11, -100000000), mm(2, 9000000 /*.09*/)}, mm(-9, -91000000 /*.091*/), nil},
|
||||
{"0+negative", args{mm(0, 0), mm(-2, -100000000)}, mm(-2, -100000000), nil},
|
||||
{"negative+0", args{mm(-2, -100000000), mm(0, 0)}, mm(-2, -100000000), nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Sum(tt.args.l, tt.args.r)
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("Sum([%v],[%v]): expected err=\"%v\" got=\"%v\"", tt.args.l, tt.args.r, tt.wantErr, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Sum([%v],[%v]) = %v, want %v", tt.args.l, tt.args.r, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
79
src/frontend/packaging_info.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright 2023 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
/*
|
||||
As part of an optional Google Cloud demo, you can run an additional "packaging" microservice (HTTP server).
|
||||
This file contains code related to the frontend and the "packaging" microservice.
|
||||
*/
|
||||
|
||||
var (
|
||||
packagingServiceUrl string
|
||||
)
|
||||
|
||||
type PackagingInfo struct {
|
||||
Weight float32 `json:"weight"`
|
||||
Width float32 `json:"width"`
|
||||
Height float32 `json:"height"`
|
||||
Depth float32 `json:"depth"`
|
||||
}
|
||||
|
||||
// init() is a special function in Golang that will run when this package is imported.
|
||||
func init() {
|
||||
packagingServiceUrl = os.Getenv("PACKAGING_SERVICE_URL")
|
||||
}
|
||||
|
||||
func isPackagingServiceConfigured() bool {
|
||||
return packagingServiceUrl != ""
|
||||
}
|
||||
|
||||
func httpGetPackagingInfo(productId string) (*PackagingInfo, error) {
|
||||
// Make the GET request
|
||||
url := packagingServiceUrl + "/" + productId
|
||||
fmt.Println("Requesting packaging info from URL: ", url)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check the response status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read the JSON response body
|
||||
responseBody, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode the JSON response into a PackagingInfo struct
|
||||
var packagingInfo PackagingInfo
|
||||
err = json.Unmarshal(responseBody, &packagingInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &packagingInfo, nil
|
||||
}
|
||||
127
src/frontend/rpc.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright 2018 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
pb "github.com/GoogleCloudPlatform/microservices-demo/src/frontend/genproto"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
avoidNoopCurrencyConversionRPC = false
|
||||
)
|
||||
|
||||
func (fe *frontendServer) getCurrencies(ctx context.Context) ([]string, error) {
|
||||
currs, err := pb.NewCurrencyServiceClient(fe.currencySvcConn).
|
||||
GetSupportedCurrencies(ctx, &pb.Empty{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, c := range currs.CurrencyCodes {
|
||||
if _, ok := whitelistedCurrencies[c]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getProducts(ctx context.Context) ([]*pb.Product, error) {
|
||||
resp, err := pb.NewProductCatalogServiceClient(fe.productCatalogSvcConn).
|
||||
ListProducts(ctx, &pb.Empty{})
|
||||
return resp.GetProducts(), err
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getProduct(ctx context.Context, id string) (*pb.Product, error) {
|
||||
resp, err := pb.NewProductCatalogServiceClient(fe.productCatalogSvcConn).
|
||||
GetProduct(ctx, &pb.GetProductRequest{Id: id})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getCart(ctx context.Context, userID string) ([]*pb.CartItem, error) {
|
||||
resp, err := pb.NewCartServiceClient(fe.cartSvcConn).GetCart(ctx, &pb.GetCartRequest{UserId: userID})
|
||||
return resp.GetItems(), err
|
||||
}
|
||||
|
||||
func (fe *frontendServer) emptyCart(ctx context.Context, userID string) error {
|
||||
_, err := pb.NewCartServiceClient(fe.cartSvcConn).EmptyCart(ctx, &pb.EmptyCartRequest{UserId: userID})
|
||||
return err
|
||||
}
|
||||
|
||||
func (fe *frontendServer) insertCart(ctx context.Context, userID, productID string, quantity int32) error {
|
||||
_, err := pb.NewCartServiceClient(fe.cartSvcConn).AddItem(ctx, &pb.AddItemRequest{
|
||||
UserId: userID,
|
||||
Item: &pb.CartItem{
|
||||
ProductId: productID,
|
||||
Quantity: quantity},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (fe *frontendServer) convertCurrency(ctx context.Context, money *pb.Money, currency string) (*pb.Money, error) {
|
||||
if avoidNoopCurrencyConversionRPC && money.GetCurrencyCode() == currency {
|
||||
return money, nil
|
||||
}
|
||||
return pb.NewCurrencyServiceClient(fe.currencySvcConn).
|
||||
Convert(ctx, &pb.CurrencyConversionRequest{
|
||||
From: money,
|
||||
ToCode: currency})
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getShippingQuote(ctx context.Context, items []*pb.CartItem, currency string) (*pb.Money, error) {
|
||||
quote, err := pb.NewShippingServiceClient(fe.shippingSvcConn).GetQuote(ctx,
|
||||
&pb.GetQuoteRequest{
|
||||
Address: nil,
|
||||
Items: items})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localized, err := fe.convertCurrency(ctx, quote.GetCostUsd(), currency)
|
||||
return localized, errors.Wrap(err, "failed to convert currency for shipping cost")
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getRecommendations(ctx context.Context, userID string, productIDs []string) ([]*pb.Product, error) {
|
||||
resp, err := pb.NewRecommendationServiceClient(fe.recommendationSvcConn).ListRecommendations(ctx,
|
||||
&pb.ListRecommendationsRequest{UserId: userID, ProductIds: productIDs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*pb.Product, len(resp.GetProductIds()))
|
||||
for i, v := range resp.GetProductIds() {
|
||||
p, err := fe.getProduct(ctx, v)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to get recommended product info (#%s)", v)
|
||||
}
|
||||
out[i] = p
|
||||
}
|
||||
if len(out) > 4 {
|
||||
out = out[:4] // take only first four to fit the UI
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (fe *frontendServer) getAd(ctx context.Context, ctxKeys []string) ([]*pb.Ad, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Millisecond*100)
|
||||
defer cancel()
|
||||
|
||||
resp, err := pb.NewAdServiceClient(fe.adSvcConn).GetAds(ctx, &pb.AdRequest{
|
||||
ContextKeys: ctxKeys,
|
||||
})
|
||||
return resp.GetAds(), errors.Wrap(err, "failed to get ads")
|
||||
}
|
||||
BIN
src/frontend/static/favicon-cymbal.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/frontend/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
170
src/frontend/static/icons/Cymbal_NavLogo.svg
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
inkscape:version="1.0 (4035a4f, 2020-05-01)"
|
||||
sodipodi:docname="Cymbal_NavLogo.svg"
|
||||
id="svg835"
|
||||
version="1.1"
|
||||
fill="none"
|
||||
viewBox="0 0 85.633156 28.251238"
|
||||
height="28.251238"
|
||||
width="85.633156">
|
||||
<metadata
|
||||
id="metadata841">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs839" />
|
||||
<sodipodi:namedview
|
||||
inkscape:current-layer="svg835"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="0"
|
||||
inkscape:cy="17.327124"
|
||||
inkscape:cx="59.485955"
|
||||
inkscape:zoom="13.692308"
|
||||
fit-margin-bottom="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-top="0"
|
||||
inkscape:snap-global="false"
|
||||
showguides="false"
|
||||
showgrid="false"
|
||||
id="namedview837"
|
||||
inkscape:window-height="1096"
|
||||
inkscape:window-width="2277"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
guidetolerance="10"
|
||||
gridtolerance="10"
|
||||
objecttolerance="10"
|
||||
borderopacity="1"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff" />
|
||||
<path
|
||||
id="path833"
|
||||
fill="#000000"
|
||||
d="m 38.248749,11.252164 c -3.156207,0 -5.719697,-2.45769 -5.719697,-5.64087 C 32.529052,2.428104 35.062912,0 38.219119,0 c 1.95596,0 3.689611,0.903134 4.771411,2.546554 0.3556,0.53299 0.6371,1.14002 0.726,1.77666 h -2.904281 c -0.42972,-1.11042 -1.4077,-1.89511 -2.62277,-1.89511 -1.76333,0 -2.904307,1.49536 -2.904307,3.16838 0,1.67303 1.140977,3.2276 2.919127,3.2276 1.21507,0 2.11896,-0.75508 2.60795,-1.83588 h 2.904281 c -0.6223,2.5761596 -2.845011,4.26396 -5.467781,4.26396 z" />
|
||||
<g
|
||||
transform="translate(63.326481,0.033206)"
|
||||
id="g867"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="m 5.56306,11.2706 c -0.8298,0 -1.9115,-0.3553 -2.3264,-1.14 H 3.20702 v 0.8587 H 0.880615 V 0.166504 H 3.31075 V 3.51255 C 3.94792,2.8463 4.7629,2.59461 5.66679,2.59461 c 0.59271,0 1.17061,0.14805 1.71887,0.39975 1.5707,0.72547 2.28196,2.20602 2.28196,3.89385 0,2.44291 -1.5707,4.38239 -4.10456,4.38239 z M 5.25189,4.85985 c -1.15579,0 -2.01523,0.90314 -2.01523,2.04316 0,1.15483 0.78534,2.08758 1.97077,2.08758 1.17062,0 2.03006,-0.91794 2.03006,-2.07277 0,-1.11042 -0.84462,-2.05797 -1.9856,-2.05797 z"
|
||||
fill="#000000"
|
||||
id="path856" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(42.505839,2.180044)"
|
||||
id="g854"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 20.8287,4.56236 V 9.00402 H 18.3986 V 4.68081 c 0,-0.88833 -0.1186,-1.80628 -1.2447,-1.80628 -1.0817,0 -1.3484,0.75509 -1.3484,1.68783 V 9.00402 H 13.3753 V 4.57717 c 0,-0.82911 -0.1926,-1.70264 -1.2151,-1.70264 -1.1261,0 -1.378,0.84392 -1.378,1.80628 V 9.00402 H 8.35205 V 3.8517 L 2.9287,11.7134 H 0.0688477 L 1.684,9.38897 V 0.875788 H 4.11413 V 5.80603 H 4.18822 L 7.53706,0.875788 H 10.6192 V 1.73451 h 0.0296 c 0.4742,-0.829111 1.3188,-1.140028 2.2375,-1.140028 1.0521,0 1.8523,0.532998 2.3412,1.450938 0.5928,-0.91794 1.4818,-1.450938 2.5784,-1.450938 0.8001,0 1.5707,0.236889 2.1337,0.814308 0.9336,0.94755 0.8891,1.90991 0.8891,3.15357 z"
|
||||
fill="#000000"
|
||||
id="path843" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(73.255785,2.027234)"
|
||||
id="g880"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 6.4988,8.98937 V 8.11585 H 6.46917 C 6.02463,8.94496 4.97256,9.27068 4.0983,9.27068 c -2.50423,0 -4.08975508,-1.93953 -4.08975508,-4.35283 0,-2.36888 1.64479508,-4.338016 4.08975508,-4.338016 0.88907,0 1.8967,0.340527 2.37087,1.140026 H 6.4988 V 0.861139 H 8.92895 V 8.98937 Z M 4.45393,2.85988 c -1.18544,0 -2.00042,0.93275 -2.00042,2.08758 0,1.12522 0.87426,2.04317 2.01524,2.04317 1.18544,0 2.03005,-0.90314 2.03005,-2.07278 0,-1.16963 -0.85944,-2.05797 -2.04487,-2.05797 z"
|
||||
fill="#000000"
|
||||
id="path869" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(83.133197,-0.02268)"
|
||||
id="g893"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 0.0698242,10.9893 V 0.166504 H 2.49996 V 11.0041 H 0.0698242 Z"
|
||||
fill="#000000"
|
||||
id="path882" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(32.708522,14.032818)"
|
||||
id="g906"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="m 4.18626,11.7607 c -2.19305,0 -3.926739,-1.1253 -3.926739,-3.44973 V 7.95564 H 3.03047 c 0,0.71067 0.32599,1.48056 1.12616,1.48056 0.57789,0 1.05206,-0.45898 1.05206,-1.03639 C 5.20869,7.68914 4.61598,7.46706 4.0529,7.21537 3.7269,7.06731 3.40091,6.93406 3.08974,6.80081 1.68204,6.20859 0.437336,5.39428 0.437336,3.69165 c 0,-2.02836 1.955964,-3.197998 3.808194,-3.197998 1.05207,0 2.25232,0.39975 2.93394,1.228858 0.56308,0.69586 0.69644,1.28808 0.72608,2.1468 H 5.16424 C 5.07533,3.26229 4.86788,2.81812 4.17144,2.81812 c -0.48899,0 -0.94834,0.34053 -0.94834,0.85872 0,0.16286 0.01482,0.32572 0.10372,0.45897 0.26672,0.44417 1.68924,0.99198 2.1486,1.19925 C 6.91275,6.00131 8.02409,6.74159 8.02409,8.44422 7.97964,10.7095 6.33485,11.7607 4.18626,11.7607 Z"
|
||||
fill="#000000"
|
||||
id="path895" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(40.443303,14.020917)"
|
||||
id="g919"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 5.89196,11.5535 V 7.12668 c 0,-0.88833 -0.25191,-1.70264 -1.30398,-1.70264 -1.05207,0 -1.36324,0.69586 -1.36324,1.61381 V 11.5535 H 0.779785 V 0.686279 H 3.22474 V 4.15077 H 3.25437 C 3.68409,3.38088 4.52871,3.12919 5.35852,3.12919 c 0.80016,0 1.68923,0.2813 2.22268,0.88833 0.78535,0.88833 0.77053,1.8655 0.77053,2.97591 V 11.5387 H 5.89196 Z"
|
||||
fill="#000000"
|
||||
id="path908" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(48.369792,16.988328)"
|
||||
id="g932"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="m 5.38968,8.80519 c -2.50422,0 -4.40091,-1.83589 -4.40091,-4.33802 0,-2.50214 1.89669,-4.33802 4.40091,-4.33802 2.50423,0 4.40092,1.83588 4.40092,4.33802 0,2.50213 -1.89669,4.33802 -4.40092,4.33802 z m 0,-6.38118 c -1.12616,0 -1.95596,0.94755 -1.95596,2.05796 0,1.11042 0.8298,2.05797 1.95596,2.05797 1.12616,0 1.95597,-0.94755 1.95597,-2.05797 0,-1.11041 -0.81499,-2.05796 -1.95597,-2.05796 z"
|
||||
fill="#000000"
|
||||
id="path921" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(58.368261,16.973537)"
|
||||
id="g945"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="m 5.40643,8.83479 c -0.96316,0 -1.79296,-0.29611 -2.45977,-1.00677 V 11.2777 H 0.501709 V 0.410455 H 2.8133 V 1.29879 H 2.82812 2.85775 C 3.49492,0.499289 4.39882,0.12915 5.40643,0.12915 7.9403,0.12915 9.39245,2.20192 9.39245,4.556 9.37763,6.83605 7.79212,8.83479 5.40643,8.83479 Z M 4.90263,2.4092 c -1.17062,0 -2.04488,0.91794 -2.04488,2.08758 0,1.14002 0.87426,2.07277 2.04488,2.07277 1.15579,0 2.04487,-0.91794 2.04487,-2.07277 C 6.93268,3.32714 6.07324,2.4092 4.90263,2.4092 Z"
|
||||
fill="#000000"
|
||||
id="path934" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(67.2903,17.010531)"
|
||||
id="g958"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="m 3.94884,8.83463 c -1.70405,0 -3.126569,-0.90314 -3.319201,-2.6946 H 3.05977 c 0.07409,0.50338 0.44454,0.75508 0.91871,0.75508 0.37045,0 0.85943,-0.20728 0.85943,-0.63664 0,-0.59222 -0.6668,-0.76988 -1.09652,-0.93274 C 3.38576,5.20728 3.03013,5.08884 2.68932,4.95559 1.72616,4.60026 0.74818,3.97843 0.74818,2.8236 c 0,-1.70264 1.58551,-2.679801 3.15621,-2.679801 1.62997,0 2.96357,0.843913 3.12657,2.576161 H 4.63046 C 4.61565,2.26098 4.28965,2.09812 3.85993,2.09812 c -0.32599,0 -0.69643,0.17767 -0.69643,0.54781 0,1.27327 4.10454,0.56261 4.10454,3.43488 0,1.82107 -1.6596,2.75382 -3.3192,2.75382 z"
|
||||
fill="#000000"
|
||||
id="path947" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(12.600322,11.81969)"
|
||||
id="g1017"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 13.0939,13.6186 V 4.09905 c 0,-1.7213 -1.408,-3.143239 -3.14566,-3.143239 H 3.29732 c -1.72265,0 -3.145709,1.406979 -3.145709,3.143239 v 6.39125 c 0,1.7213 1.408079,3.1433 3.145709,3.1433 z"
|
||||
fill="#840237"
|
||||
id="path1006" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5.816672,0.035276)"
|
||||
id="g1030"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 13.6377,9.82748 V 3.48112 c 0,-1.75124 -1.4231,-3.188151 -3.1907,-3.188151 H 3.84102 c -1.72265,0 -3.145708,1.406971 -3.145708,3.143241 V 12.9558 H 10.477 c 1.7376,0 3.1607,-1.407 3.1607,-3.12832 z"
|
||||
fill="#ce0631"
|
||||
id="path1019" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-0.239258,12.021229)"
|
||||
id="g1043"
|
||||
style="fill:none">
|
||||
<path
|
||||
d="M 13.1666,10.4903 V 4.09905 c 0,-1.7213 -1.4081,-3.143239 -3.1457,-3.143239 H 0.239258 V 10.4753 c 0,1.7213 1.408082,3.1433 3.145712,3.1433 h 6.65093 c 1.7226,0 3.1307,-1.407 3.1307,-3.1283 z"
|
||||
fill="#ff6631"
|
||||
id="path1032" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
1
src/frontend/static/icons/Hipster_Advert2.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
69
src/frontend/static/icons/Hipster_CartIcon.svg
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
inkscape:version="1.0 (4035a4f, 2020-05-01)"
|
||||
height="11.002236"
|
||||
width="10.434605"
|
||||
sodipodi:docname="Hipster_CartIcon.svg"
|
||||
version="1.1"
|
||||
viewBox="0 0 10.434605 11.002236"
|
||||
data-name="Layer 1"
|
||||
id="Layer_1">
|
||||
<metadata
|
||||
id="metadata1201">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title>Hipster</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
inkscape:current-layer="g1196"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="625"
|
||||
inkscape:cy="-0.89051508"
|
||||
inkscape:cx="22.434662"
|
||||
inkscape:zoom="20.841972"
|
||||
fit-margin-bottom="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-top="0"
|
||||
showgrid="false"
|
||||
id="namedview1199"
|
||||
inkscape:window-height="1387"
|
||||
inkscape:window-width="1935"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
guidetolerance="10"
|
||||
gridtolerance="10"
|
||||
objecttolerance="10"
|
||||
borderopacity="1"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff" />
|
||||
<defs
|
||||
id="defs1188">
|
||||
<style
|
||||
id="style1186">.cls-1{fill:#b4b2bb}</style>
|
||||
</defs>
|
||||
<title
|
||||
id="title1190">Hipster</title>
|
||||
<g
|
||||
transform="translate(-4.4609375,-5.5)"
|
||||
id="g1196">
|
||||
<path
|
||||
sodipodi:nodetypes="ccccccsccccccccccccccccccssssssss"
|
||||
d="m 4.4609375,5.5 v 1.0996094 h 1.0996094 l 2,4.1699216 -0.75,1.34961 c -0.083803,0.164211 -0.1291659,0.346898 -0.1308594,0.53125 0,0.607513 0.4920962,1.099609 1.0996094,1.099609 H 14.380859 V 12.650391 H 8 c -0.081192,0.0058 -0.1505985,-0.05923 -0.1503906,-0.140625 v -0.06055 l 0.4902344,-0.898438 h 4.0996092 c 0.414076,0.01348 0.800604,-0.207156 1,-0.570312 l 1.456089,-4.4008431 -8.1260108,0.019984 v 0 L 6.25,5.5 Z M 7.1729534,7.576157 13.673828,7.54223 12.681641,10.603516 8.5214844,10.589846 Z m 0.5868122,6.724624 c -0.9800635,0 -1.4702693,1.184027 -0.7773437,1.876953 0.6929255,0.692925 1.8769531,0.20272 1.8769531,-0.777343 0,-0.607513 -0.4920962,-1.09961 -1.0996094,-1.09961 z m 5.5000004,0 c -0.980063,0 -1.470269,1.184027 -0.777344,1.876953 0.692926,0.692925 1.876953,0.20272 1.876953,-0.777343 0,-0.607513 -0.492096,-1.09961 -1.099609,-1.09961 z"
|
||||
id="path1192" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
src/frontend/static/icons/Hipster_CheckOutIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 20 20"><defs><style>.cls-1{fill:#b4b2bb}</style></defs><title>Hipster</title><g><path d="M14.5,5.5h-9A1.12,1.12,0,0,0,4.38,6.62v6.76A1.12,1.12,0,0,0,5.5,14.5h9a1.12,1.12,0,0,0,1.12-1.12V6.62A1.12,1.12,0,0,0,14.5,5.5Zm0,7.88h-9V10h9Zm0-5.63h-9V6.62h9Z" class="cls-1"/><path d="M10,2a8,8,0,1,1-8,8,8,8,0,0,1,8-8m0-2A10,10,0,1,0,20,10,10,10,0,0,0,10,0Z" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 462 B |
1
src/frontend/static/icons/Hipster_CurrencyIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 20 20"><defs><style>.cls-1{fill:#605f64}</style></defs><title>Hipster</title><g><path d="M10.28,9.21c-1.64-.43-2.16-.87-2.16-1.56s.72-1.33,2-1.33,1.76.61,1.8,1.51h1.6a2.88,2.88,0,0,0-2.32-2.75V3.5H9V5.06A2.82,2.82,0,0,0,6.45,7.67c0,1.67,1.38,2.5,3.4,3,1.8.43,2.16,1.07,2.16,1.74,0,.5-.35,1.29-1.95,1.29S8,13,7.91,12.17H6.32A3,3,0,0,0,9,14.93V16.5h2.17V15c1.41-.27,2.53-1.09,2.53-2.57C13.68,10.33,11.92,9.63,10.28,9.21Z" class="cls-1"/><path d="M10,2a8,8,0,1,1-8,8,8,8,0,0,1,8-8m0-2A10,10,0,1,0,20,10,10,10,0,0,0,10,0Z" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 630 B |
63
src/frontend/static/icons/Hipster_DownArrow.svg
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
inkscape:version="1.0 (4035a4f, 2020-05-01)"
|
||||
sodipodi:docname="Hipster_DownArrow.svg"
|
||||
version="1.1"
|
||||
viewBox="0 0 10 6"
|
||||
data-name="Layer 1"
|
||||
id="Layer_1">
|
||||
<metadata
|
||||
id="metadata1241">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
inkscape:current-layer="g1805"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="432"
|
||||
inkscape:window-x="453"
|
||||
inkscape:cy="7.8920446"
|
||||
inkscape:cx="9.2114456"
|
||||
inkscape:zoom="27.4"
|
||||
showgrid="false"
|
||||
id="namedview1239"
|
||||
inkscape:window-height="815"
|
||||
inkscape:window-width="1338"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
guidetolerance="10"
|
||||
gridtolerance="10"
|
||||
objecttolerance="10"
|
||||
borderopacity="1"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff" />
|
||||
<defs
|
||||
id="defs1232">
|
||||
<style
|
||||
id="style1230">.cls-1{fill:#605f64}</style>
|
||||
</defs>
|
||||
<title
|
||||
id="title1234">Hipster</title>
|
||||
<g
|
||||
style="opacity:1"
|
||||
id="g1805">
|
||||
<path
|
||||
sodipodi:nodetypes="cccc"
|
||||
id="path1841"
|
||||
d="M 0.04897564,0.08494176 5.0087697,6.0165613 9.9685637,0.08494176 Z"
|
||||
style="fill:#5c6063;stroke:none;stroke-width:0.08082460000000000px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
src/frontend/static/icons/Hipster_FacebookIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 48 48"><defs><style>.cls-1{fill:#111}</style></defs><title>Hipster</title><g><path d="M24,0A24,24,0,1,0,48,24,24,24,0,0,0,24,0ZM14.42,17.76H10.24v4.87h3.28v2.31H10.24v6.68H7.69V15.45h6.73ZM21,31.62l-.44-2.94H17.44L17,31.62H14.67l2.59-16.17H21l2.58,16.17ZM32.11,20.9h-2.4V19.16c0-1.15-.51-1.59-1.32-1.59s-1.32.44-1.32,1.59V27.9c0,1.15.51,1.57,1.32,1.57s1.32-.42,1.32-1.57V25.59h2.4v2.15c0,2.58-1.29,4.06-3.79,4.06s-3.79-1.48-3.79-4.06V19.33c0-2.59,1.29-4.07,3.79-4.07s3.79,1.48,3.79,4.07Zm8.47-3.14H36.19v4.5h3.49v2.31H36.19v4.74h4.39v2.31H33.65V15.45h6.93Z" class="cls-1"/><polygon points="17.77 26.49 20.21 26.49 18.99 18.31 17.77 26.49" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 750 B |
1
src/frontend/static/icons/Hipster_GooglePlayIcon.svg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
1
src/frontend/static/icons/Hipster_HelpIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 20 20"><defs><style>.cls-1{fill:#605f64}</style></defs><title>Hipster</title><g><path d="M9,16h2V14H9ZM10,4A4,4,0,0,0,6,8H8a2,2,0,0,1,4,0c0,2-3,1.75-3,5h2c0-2.25,3-2.5,3-5A4,4,0,0,0,10,4Z" class="cls-1"/><path d="M10,2a8,8,0,1,1-8,8,8,8,0,0,1,8-8m0-2A10,10,0,1,0,20,10,10,10,0,0,0,10,0Z" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 399 B |
1
src/frontend/static/icons/Hipster_HeroLogo.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
235
src/frontend/static/icons/Hipster_HeroLogoMaroon.svg
Normal file
@@ -0,0 +1,235 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
viewBox="0 0 625.15 469.57"
|
||||
version="1.1"
|
||||
sodipodi:docname="Hipster_HeroLogoMaroon.svg"
|
||||
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<sodipodi:namedview
|
||||
id="namedview213"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.0051749"
|
||||
inkscape:cx="299.94779"
|
||||
inkscape:cy="275.0765"
|
||||
inkscape:window-width="1724"
|
||||
inkscape:window-height="905"
|
||||
inkscape:window-x="204"
|
||||
inkscape:window-y="1141"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g210" />
|
||||
<defs
|
||||
id="defs134">
|
||||
<style
|
||||
id="style132">.cls-1{fill:#4bc7c7}</style>
|
||||
</defs>
|
||||
<title
|
||||
id="title136">Hipster</title>
|
||||
<g
|
||||
id="g210"
|
||||
style="stroke-width:1.75748031;stroke-dasharray:none;paint-order:stroke markers fill;stroke:#ffffff;stroke-opacity:1">
|
||||
<g
|
||||
id="g1317-3"
|
||||
transform="matrix(1.0146883,0,0,1.0241514,-4.2853293,-6.1372338)"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.72401905;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill">
|
||||
<g
|
||||
id="g196-5"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
transform="matrix(0.97135514,0,0,0.96186606,8.9536653,8.9521229)">
|
||||
<path
|
||||
d="m 214.76,32.83 -4.44,-9 a 231,231 0 0 1 202.19,-1.14 l -4.34,9 a 221,221 0 0 0 -193.41,1.09 z"
|
||||
class="cls-1"
|
||||
id="path138-2"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<g
|
||||
id="g144-2"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill">
|
||||
<path
|
||||
d="m 120.84,339.47 q -1.55,-2.72 -3,-5.47 h -11.27 q 2.66,5.28 5.58,10.42 a 232,232 0 0 0 79.29,82.22 l 5.26,-8.5 a 222,222 0 0 1 -75.86,-78.67 z"
|
||||
class="cls-1"
|
||||
id="path140-6"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 117.78,127 a 221.75,221.75 0 0 1 21.57,-32.94 l -7.85,-6.2 a 231.18,231.18 0 0 0 -25,39.14 z"
|
||||
class="cls-1"
|
||||
id="path142-6"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
</g>
|
||||
<g
|
||||
id="g158-7"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill">
|
||||
<path
|
||||
d="m 198.23,116.53 -0.38,-0.86 -0.29,-0.77 -0.58,-1.73 -0.19,-0.86 -0.1,-0.87 -0.19,-0.86 -0.29,-2.6 v -1.82 l 0.1,-0.87 v -2.69 l 0.19,-1.72 0.19,-1 0.1,-0.87 0.19,-0.77 0.19,-0.86 0.29,-1 0.39,-1.06 0.57,-1.92 0.39,-1.06 0.38,-0.86 0.39,-0.77 0.38,-0.86 0.38,-0.77 0.39,-0.87 0.48,-0.76 0.48,-0.68 1,-0.57 1.06,-0.29 0.86,0.09 0.77,0.29 0.77,0.48 0.76,0.77 0.49,1.06 v 0.86 l -0.49,1.15 -0.47,1.25 -0.29,0.58 -0.39,1 -0.38,0.77 -0.29,0.86 -0.19,0.87 -0.29,1.15 -0.29,1.25 -0.19,0.86 -0.1,0.87 -0.19,0.86 -0.09,0.87 v 0.76 l -0.2,1.73 v 0.87 l 0.1,1 v 1.73 l 0.1,0.86 0.19,0.87 0.19,1.24 0.29,1.54 0.38,1.25 0.29,0.86 0.77,1.54 0.58,0.77 0.47,0.58 0.58,0.67 1.35,1.15 0.67,0.48 0.77,0.38 0.86,0.29 1.25,0.29 1.15,0.19 0.87,0.2 h 2.49 l 0.87,-0.2 0.86,-0.09 0.87,-0.29 1,-0.39 0.77,-0.38 1.44,-0.86 0.77,-0.48 1.35,-1 1.92,-1.93 0.57,-0.67 0.48,-0.67 0.48,-0.77 0.58,-0.86 0.38,-0.48 1.06,-1.54 0.38,-0.67 0.48,-0.77 0.39,-0.86 0.48,-0.87 0.67,-1.44 0.3,-0.83 0.38,-0.77 0.1,-0.77 -0.29,-0.39 -0.57,-0.19 -0.87,-0.09 -1,-0.1 -0.86,-0.19 -0.77,-0.19 -0.87,-0.29 -0.86,-0.19 -0.77,-0.29 -0.77,-0.39 -2.3,-0.86 -1.06,-0.38 -2.88,-1.73 -0.77,-0.48 -1.44,-1 -0.67,-0.58 -3.65,-3.65 -0.48,-0.67 -0.58,-0.67 -1,-1.45 -0.38,-0.67 -0.39,-0.86 -0.77,-1.54 -0.38,-1.06 -0.38,-1 -0.29,-1 -0.39,-1.05 -0.28,-0.87 -0.2,-1.73 -0.09,-1 v -0.4 l -0.19,-1.92 0.09,-0.87 0.1,-1 0.09,-0.57 0.2,-1.06 0.24,-1.08 0.38,-1.25 0.29,-0.87 0.48,-1.15 0.48,-1.06 0.38,-0.86 0.48,-0.67 1.16,-1.35 0.57,-0.57 1.38,-1.22 1.05,-0.76 1.35,-0.74 2.69,-1.35 0.86,-0.29 1.25,-0.28 1.15,-0.29 1,-0.19 1.16,-0.1 h 2.21 l 1.53,0.19 1.92,0.1 1.16,0.19 1.25,0.29 0.86,0.38 1.73,0.58 1.05,0.57 1.64,1 1,0.57 0.67,0.48 0.67,0.58 1.15,1.34 0.49,0.68 0.48,0.76 0.48,0.68 0.38,0.76 0.38,0.87 0.77,1.54 0.29,0.76 0.29,0.87 0.38,1.73 0.19,1.25 0.29,1.15 0.1,1 0.1,0.58 0.09,1 0.1,0.87 v 0.86 l 0.19,1.73 v 0.86 l -0.1,1 -0.09,0.87 -0.19,0.77 v 1 l -0.1,0.58 v 1.06 l -0.19,0.86 -0.28,1.14 -0.19,1.25 -0.19,1 -0.1,0.87 -0.09,0.76 -0.19,0.87 -0.29,0.86 v 1.06 l 0.57,0.58 h 1.06 l 0.87,-0.2 1.05,-0.28 0.58,-0.2 1.82,-0.48 1.06,-0.38 1.25,-0.48 1,-0.38 0.87,-0.49 0.58,-0.28 0.86,-0.48 0.86,-0.39 0.77,-0.09 0.48,0.38 0.1,0.86 -0.19,0.77 -0.29,0.87 -0.39,0.77 -0.57,0.57 -0.77,0.48 -1,0.39 -0.48,0.28 -1.73,0.87 -0.86,0.38 -0.77,0.29 -1,0.39 -0.58,0.19 -1,0.48 -0.77,0.29 -0.77,0.19 -0.86,0.19 -1,0.29 -1,0.19 -0.87,0.1 -0.76,0.19 -0.58,0.48 -0.49,1.49 -0.29,0.86 -0.51,1.14 -1.44,2.88 -0.29,0.48 -0.48,0.87 -0.38,0.86 -1,1.54 -0.38,0.48 -0.58,0.86 -0.48,0.68 -1.11,1.35 -0.48,0.77 -0.58,0.67 -0.67,0.58 -0.68,0.67 -0.57,0.48 -1.25,1.25 -1.34,1.15 -0.77,0.48 -1.06,0.58 -1.15,0.67 -0.87,0.48 -0.76,0.39 -0.87,0.28 -0.77,0.2 -0.86,0.28 -0.87,0.2 -0.86,0.28 -1,0.1 -0.58,0.1 -1,0.09 -0.86,0.1 h -2.61 l -1.15,-0.19 -1.06,-0.1 -2.39,-0.51 -1.15,-0.29 -0.87,-0.29 -0.77,-0.28 -0.86,-0.49 -1.92,-1.34 -0.77,-0.67 -0.58,-0.58 -0.67,-0.57 -0.58,-0.58 -0.57,-0.77 -0.67,-1 -0.58,-1.06 -0.48,-0.86 z m 17.1,-39.86 v 2.6 l 0.09,1 0.2,0.86 0.09,0.86 0.58,1.73 0.29,0.77 0.38,0.87 0.38,0.76 0.48,0.68 0.48,0.86 0.39,0.77 1.15,1.34 0.67,0.68 0.39,0.48 1.34,1.34 0.67,0.58 0.77,0.57 0.68,0.48 0.76,0.48 0.68,0.48 1,0.49 0.57,0.19 1.06,0.48 0.58,0.19 1,0.38 2.4,0.77 1,0.19 0.86,0.2 0.77,-0.1 0.67,-0.67 0.39,-1.06 0.57,-2.59 0.19,-1.25 0.1,-1.15 0.1,-1 0.29,-2.6 v -0.86 l 0.19,-1.15 v -5.19 l -0.1,-1.15 -0.09,-1 -0.2,-1.16 -0.19,-1.24 -0.38,-1.73 -0.29,-0.87 -0.48,-1.15 -0.39,-1.06 -0.38,-0.86 -0.48,-0.77 -0.48,-0.67 -0.48,-0.48 -0.58,-0.67 -0.76,-0.58 -0.68,-0.48 -0.77,-0.38 -0.86,-0.39 -1.25,-0.29 -1.7,-0.15 -1.2,-0.05 -1,0.1 -1.25,0.19 -1.44,0.48 -1.44,0.77 -1.35,0.87 -1.15,1 -0.87,1 -0.38,0.77 -0.48,1.15 -0.44,1.4 -0.29,1.27 -0.19,1 z"
|
||||
class="cls-1"
|
||||
id="path146-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 275.16,91.85 -0.76,0.38 -0.68,0.39 -0.67,0.48 -0.77,0.57 -0.67,0.58 -1.54,1.06 -0.67,0.48 -0.67,0.57 -0.67,0.67 -1.16,1.35 -0.67,0.67 -1.15,1.35 -1,1.53 -0.58,0.77 -1.11,2.3 -1.06,3.17 -0.28,1.24 -0.39,1.44 -0.38,1.25 -0.58,2.6 -0.38,1.24 -0.39,1.45 -0.28,1.24 -0.29,0.87 -0.29,1.25 -0.19,1 -0.29,1 -0.1,0.87 -0.19,0.77 -0.29,0.77 -0.57,0.48 -0.87,0.28 -0.86,0.1 -1.25,0.1 -1.46,-0.12 -1.25,-0.19 -0.77,-0.39 -0.58,-0.86 -0.09,-1.06 0.19,-1 0.29,-1.25 0.29,-1.15 0.19,-0.86 0.29,-0.87 0.19,-0.86 0.29,-0.77 0.29,-0.87 0.19,-0.86 0.19,-0.77 0.19,-0.86 0.29,-0.87 0.29,-0.77 0.38,-1.72 0.19,-0.77 0.1,-0.87 0.29,-0.86 0.19,-0.87 0.29,-0.86 0.29,-1.15 0.38,-1.25 0.48,-1.73 0.19,-0.77 0.2,-0.86 0.28,-0.87 0.2,-0.86 0.28,-0.77 0.2,-0.86 0.28,-0.87 0.29,-1.25 0.29,-1.15 0.19,-0.86 0.58,-1.73 0.19,-0.77 0.29,-0.87 0.38,-1.72 0.68,-1 1.24,-0.67 1.54,-0.19 1.54,0.19 1.44,0.48 1,1 0.19,1.16 -0.19,1 -0.19,0.67 -0.29,0.86 -0.19,0.87 -0.29,0.77 -0.19,0.86 -0.29,0.86 -0.19,0.87 -0.1,0.58 0.1,0.09 0.38,-0.48 0.58,-0.67 0.48,-0.67 1.15,-1.35 0.67,-0.57 1.25,-1.06 0.67,-0.58 1.25,-1.25 1.92,-1.53 0.77,-0.48 0.77,-0.39 0.77,-0.48 0.77,-0.29 0.86,-0.28 1.25,-0.29 1.15,-0.19 h 2.21 l 1.54,0.19 1.05,0.19 0.87,0.38 0.86,0.48 0.67,0.68 0.48,0.67 0.48,0.77 0.39,0.77 0.29,0.86 0.29,1.15 0.19,1.16 v 1.82 l -0.1,0.87 v 0.86 l -0.09,0.77 -0.2,0.86 -0.19,1 -0.19,0.58 -0.29,1 -0.29,1.25 -0.38,1.4 -0.39,1.25 -0.28,0.86 -0.29,1.25 -0.39,1.63 -0.38,1.54 -0.38,1.34 -0.2,0.87 -0.19,0.67 -0.19,0.87 -0.29,0.86 -0.19,0.77 -0.58,2.59 -0.09,0.87 v 1.72 l 0.38,0.77 0.67,0.48 0.87,0.1 0.86,-0.29 0.87,-0.38 0.76,-0.48 0.68,-0.48 0.77,-0.58 1.24,-1.25 0.48,-0.67 0.58,-0.77 0.48,-0.67 0.67,-0.77 0.48,-0.67 0.58,-0.87 0.86,-1.44 0.58,-0.86 0.29,-0.48 0.57,-0.87 0.48,-0.86 0.77,-1.54 0.39,-0.86 0.29,-0.77 0.24,-0.88 0.48,-0.77 0.87,-0.58 1.25,0.29 0.76,1 v 1 l -0.28,0.86 -0.39,0.77 -0.38,0.87 -0.39,0.77 -0.48,0.76 -0.38,0.77 -0.58,0.87 -0.28,0.57 -1,1.73 -0.19,0.58 -0.48,0.86 -1,1.54 -0.57,0.86 -0.39,0.48 -0.57,0.77 -0.68,0.87 -0.57,0.67 -1.06,1.15 -0.58,0.67 -1.34,1.16 -0.58,0.57 -0.76,0.58 -0.77,0.48 -1.54,0.77 -0.77,0.29 -0.86,0.28 -0.87,0.2 -0.77,0.09 -1,0.1 h -0.86 l -0.86,-0.1 -0.77,-0.19 -0.87,-0.29 -0.77,-0.29 -0.76,-0.48 -0.58,-0.48 -0.58,-0.67 -0.38,-0.86 -0.29,-0.77 -0.19,-0.87 -0.19,-0.76 V 119 l 0.19,-1.25 0.09,-1 0.29,-1.25 0.87,-3.16 0.29,-1.64 0.52,-1.7 0.38,-1.54 0.39,-1.25 0.29,-0.86 0.38,-1.25 0.38,-1.54 0.29,-1.53 0.39,-1.25 0.38,-1.73 0.1,-0.86 0.38,-1.83 0.1,-0.86 -0.19,-1 -0.58,-1 -0.87,-0.87 -0.86,-0.29 -0.86,-0.09 -0.87,0.09 z"
|
||||
class="cls-1"
|
||||
id="path148-4"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 327,79.65 -0.48,0.86 -0.77,1.54 -0.48,1.06 -0.67,1 -0.48,0.68 -0.48,0.76 -0.48,0.68 -0.48,0.86 -0.39,0.58 -0.48,0.77 -0.67,0.86 -0.86,1 -0.87,0.87 -0.38,0.48 -0.67,0.76 -1.25,1.25 -0.58,0.67 -1.03,1.17 -0.67,0.57 -0.77,0.77 -1.05,0.77 -1.44,1.25 -0.77,0.48 -2.11,1.34 -0.69,0.32 -0.77,0.48 -1.05,0.87 -0.67,0.86 -0.39,0.77 -0.19,1.06 -0.29,1.72 v 0.58 l -0.19,1 -0.19,0.87 -0.1,1 v 2 l -0.09,1.25 v 1.92 l 0.19,1.06 0.09,0.58 0.2,1 0.38,0.86 0.38,0.77 0.58,0.67 0.77,0.48 0.77,0.29 1,0.19 H 310 l 0.77,-0.09 0.86,-0.29 1.16,-0.48 1.05,-0.58 0.77,-0.58 1,-0.76 0.87,-0.87 1.15,-1.06 0.77,-0.67 0.6,-0.9 0.39,-0.48 0.57,-0.76 0.39,-0.68 0.57,-0.86 0.39,-0.48 0.57,-0.87 0.77,-1.44 0.48,-0.76 0.58,-0.87 0.48,-0.86 0.48,-1 0.86,-1.25 1,-0.77 1.25,0.19 0.77,1 v 1 l -0.29,0.87 -0.38,0.76 -0.48,0.77 -0.39,0.87 -0.38,0.77 -0.29,0.76 -0.48,0.77 -0.38,0.68 -0.48,0.76 -0.58,0.87 -0.19,0.48 -0.48,0.86 -0.58,0.77 -1,1.35 -0.67,0.86 -0.39,0.38 -0.67,0.87 -0.57,0.67 -0.77,1 -0.87,0.77 -0.67,0.67 -0.57,0.48 -0.68,0.58 -0.86,0.48 -0.77,0.38 -0.86,0.39 -0.77,0.38 -0.77,0.29 -0.87,0.38 -1.15,0.39 -1.92,0.38 -1.15,0.1 -1.06,-0.1 h -0.57 l -1,-0.09 -0.87,-0.2 -0.77,-0.28 -0.86,-0.29 -0.77,-0.39 -0.77,-0.48 -0.67,-0.57 -0.58,-0.58 -0.57,-0.67 -0.39,-0.77 -0.38,-0.86 -0.29,-0.77 -0.19,-0.87 -0.29,-0.86 -0.1,-0.87 -0.19,-1 v -0.57 l -0.19,-1.06 -0.1,-0.86 v -0.87 l 0.1,-0.86 0.1,-1 v -0.58 l 0.09,-1.25 0.29,-2.11 0.1,-1.15 v -0.87 l 0.19,-0.86 0.19,-1 0.67,-3.17 0.29,-1.15 0.29,-1.73 0.19,-0.48 0.29,-1.16 0.19,-0.57 0.39,-1.44 0.28,-1.25 0.2,-0.87 0.28,-1.15 0.39,-1.34 0.09,-0.68 0.39,-1.34 0.38,-1.25 0.29,-0.67 0.38,-1.25 0.39,-1.15 0.38,-0.87 1,-2.49 0.2,-0.58 0.57,-1.35 0.67,-1.53 0.48,-1.15 0.39,-0.87 0.29,-0.57 0.48,-0.87 0.67,-1.63 0.48,-1 0.48,-0.87 0.58,-0.86 0.76,-1.25 1.21,-1.67 0.58,-0.77 1.25,-1.23 0.38,-0.48 0.67,-0.67 0.68,-0.48 1.53,-1.06 1.06,-0.67 1.06,-0.48 0.86,-0.29 0.87,-0.19 h 1.72 l 1,0.19 0.58,0.1 1,0.29 0.77,0.38 1.34,1.16 0.48,0.76 0.68,1 0.48,1.16 0.38,0.86 0.38,1.15 0.2,1.25 v 1.73 l -0.2,2.59 -0.41,1.7 -0.29,0.77 -0.19,1 -0.82,2.23 -0.38,0.77 z m -15.94,2.69 -1.35,4 -0.19,0.87 -0.29,0.86 -0.38,1.25 -0.67,2 -0.29,1 -0.1,0.57 -0.29,1 -0.29,0.87 -0.19,1 0.19,0.58 0.58,-0.39 1,-0.77 3.94,-3.93 0.48,-0.68 0.48,-0.77 0.58,-0.86 0.38,-0.38 0.58,-0.87 0.67,-0.86 0.67,-1 0.67,-1.06 0.48,-0.77 0.39,-0.67 0.48,-0.67 0.77,-1.54 0.38,-1 0.29,-0.58 0.38,-1 0.39,-0.86 0.19,-0.87 0.29,-0.86 0.29,-1.06 0.38,-1 0.29,-1.06 0.38,-1.06 0.1,-1 0.09,-0.86 v -1.79 l -0.19,-0.76 -0.29,-0.77 -0.67,-0.48 -0.77,-0.19 -0.86,0.19 -0.87,0.67 -0.67,0.58 -0.48,0.57 -0.48,0.67 -0.48,0.77 -1,1.44 -0.48,0.87 -0.38,0.86 -0.39,0.77 -0.38,0.87 -0.39,0.76 -0.38,0.87 -0.29,0.77 -0.38,1 -0.58,1.44 -0.38,0.87 z"
|
||||
class="cls-1"
|
||||
id="path150-1"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 348.16,105.1 v 1.06 l -0.29,0.86 -0.77,1.54 -0.86,1.44 -0.48,0.86 -0.39,0.77 -0.57,0.87 -0.29,0.57 -0.48,0.87 -0.48,0.77 -0.48,0.67 -0.58,0.77 -0.48,0.67 -1,1.54 -1.73,2 -1.15,1.25 -0.67,0.67 -0.58,0.68 -0.67,0.57 -2.11,1.64 -0.68,0.48 -0.86,0.38 -0.86,0.29 -0.77,0.19 -1.73,0.38 h -1.63 l -1.06,-0.09 -0.77,-0.29 -0.86,-0.29 -0.77,-0.48 -0.67,-0.57 -0.48,-0.68 -0.48,-0.77 -0.39,-0.86 -0.19,-0.77 -0.19,-0.86 -0.1,-0.87 v -0.86 l 0.19,-1.73 0.2,-0.86 0.28,-0.87 0.39,-1.73 0.29,-0.77 0.19,-0.86 0.29,-0.87 0.28,-0.76 0.87,-2.6 0.09,-0.77 0.2,-0.86 0.28,-0.86 0.2,-0.87 0.19,-0.77 0.57,-1.73 0.29,-1.15 0.29,-1.25 0.58,-1.73 0.19,-0.86 0.19,-0.67 0.19,-0.87 0.39,-1 0.29,-0.77 0.57,-2.59 0.29,-0.86 0.29,-1.16 0.38,-1.24 0.29,-0.87 0.39,-0.77 0.57,-0.48 0.87,-0.38 0.76,-0.1 1,0.1 1.73,0.19 1.54,0.58 0.48,0.67 v 0.86 l -0.19,0.87 -0.29,1.15 -0.29,1.25 -0.57,1.73 -0.2,0.77 -0.19,0.86 -0.29,1 -0.28,1.13 -1,2.89 -0.19,0.86 -0.38,1.25 -0.67,2 -0.2,0.86 -0.19,0.77 -0.19,0.86 -0.67,1.64 -0.19,0.86 -0.68,2.4 -0.57,1.73 -0.2,0.86 -0.28,1.25 -0.29,1.54 -0.19,1.63 0.19,1.44 0.77,1.06 1.05,0.29 0.87,-0.1 0.86,-0.38 0.67,-0.48 0.77,-0.58 3.17,-3.17 0.58,-0.67 0.48,-0.67 0.58,-0.77 1,-1.54 0.57,-0.67 0.39,-0.58 0.48,-0.77 0.57,-0.86 0.39,-0.86 0.77,-1.64 1.15,-2.3 0.48,-0.77 0.86,-0.48 1.25,0.19 z m -15.46,-29.48 0.48,-0.77 0.76,-0.67 0.77,-0.49 1,-0.28 0.87,-0.2 h 1 l 0.86,0.2 0.77,0.48 0.67,0.57 0.68,0.68 0.48,0.76 0.28,0.87 0.1,0.86 v 1 l -0.29,0.87 -0.48,0.77 -0.65,0.73 -0.68,0.58 -0.77,0.58 -0.86,0.38 -1,0.19 -0.86,-0.09 -1,-0.2 -1.54,-1 -0.58,-0.67 -0.48,-0.77 -0.19,-0.86 -0.1,-0.87 0.1,-0.86 0.19,-1 z"
|
||||
class="cls-1"
|
||||
id="path152-7"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 365.93,91.85 -0.77,0.38 -0.67,0.39 -0.67,0.48 -0.77,0.57 -0.68,0.58 -1.53,1.06 -0.67,0.48 -0.68,0.57 -0.67,0.67 -1.15,1.35 -0.67,0.67 -1.16,1.35 -1,1.53 -0.57,0.77 -1.12,2.3 -1,3.17 -0.29,1.24 -0.38,1.44 -0.39,1.25 -0.57,2.6 -0.39,1.24 -0.38,1.45 -0.29,1.24 -0.29,0.87 -0.29,1.25 -0.19,1 -0.29,1 -0.09,0.87 -0.2,0.77 -0.28,0.77 -0.58,0.48 -0.86,0.28 -0.87,0.1 -1.25,0.1 -1.44,-0.1 -1.25,-0.19 -0.77,-0.39 -0.57,-0.86 -0.1,-1.06 0.19,-1 0.29,-1.25 0.29,-1.15 0.19,-0.86 0.29,-0.87 0.19,-0.86 0.29,-0.77 0.29,-0.87 0.19,-0.86 0.19,-0.77 0.19,-0.86 0.29,-0.87 0.29,-0.77 0.39,-1.72 0.19,-0.77 0.09,-0.87 0.29,-0.86 0.19,-0.87 0.29,-0.86 0.29,-1.15 0.38,-1.25 0.48,-1.73 0.2,-0.77 0.19,-0.86 0.29,-0.87 0.19,-0.86 0.29,-0.77 0.19,-0.86 0.29,-0.87 0.29,-1.25 0.28,-1.15 0.2,-0.86 0.57,-1.73 0.19,-0.77 0.29,-0.87 0.39,-1.72 0.67,-1 1.25,-0.67 1.53,-0.19 1.54,0.19 1.44,0.48 1,1 0.19,1.16 -0.19,1 -0.19,0.67 -0.29,0.86 -0.19,0.87 -0.29,0.77 -0.19,0.86 -0.29,0.86 -0.19,0.87 -0.1,0.58 0.1,0.09 0.38,-0.48 0.58,-0.67 0.48,-0.67 1.15,-1.35 0.67,-0.57 1.19,-1.05 0.68,-0.58 1.24,-1.25 1.93,-1.53 0.76,-0.48 0.77,-0.39 0.77,-0.48 0.77,-0.29 0.86,-0.28 1.25,-0.29 1.15,-0.19 h 2.21 l 1.54,0.19 1.06,0.19 0.86,0.38 0.87,0.48 0.67,0.68 0.48,0.67 0.48,0.77 0.38,0.77 0.29,0.86 0.29,1.15 0.19,1.16 v 1.82 l -0.1,0.87 v 0.86 l -0.09,0.77 -0.19,0.86 -0.2,1 -0.19,0.58 -0.29,1 -0.28,1.25 -0.39,1.44 -0.38,1.25 -0.29,0.86 -0.29,1.25 -0.41,1.57 -0.39,1.54 -0.38,1.34 -0.19,0.87 -0.2,0.67 -0.19,0.87 -0.29,0.86 -0.19,0.77 -0.57,2.59 -0.1,0.87 v 1.72 l 0.38,0.77 0.68,0.48 0.86,0.1 0.87,-0.29 0.86,-0.38 0.77,-0.48 0.67,-0.48 0.77,-0.58 1.24,-1.24 0.48,-0.67 0.57,-0.77 0.48,-0.67 0.68,-0.77 0.48,-0.67 0.57,-0.87 0.87,-1.44 0.57,-0.86 0.29,-0.48 0.58,-0.87 0.48,-0.86 0.77,-1.54 0.38,-0.86 0.29,-0.77 0.29,-0.86 0.48,-0.77 0.86,-0.58 1.25,0.29 0.77,1 v 1 l -0.29,0.86 -0.38,0.77 -0.39,0.87 -0.38,0.77 -0.48,0.76 -0.39,0.77 -0.57,0.87 -0.29,0.57 -1,1.73 -0.19,0.58 -0.48,0.86 -1,1.54 -0.58,0.86 -0.38,0.48 -0.58,0.77 -0.67,0.87 -0.58,0.67 -1.06,1.15 -0.57,0.67 -1.35,1.16 -0.57,0.57 -0.77,0.58 -0.77,0.48 -1.54,0.77 -0.76,0.29 -0.87,0.28 -0.86,0.2 -0.77,0.09 -1,0.1 h -0.87 l -0.86,-0.1 -0.77,-0.19 -0.86,-0.29 -0.77,-0.29 -0.77,-0.48 -0.58,-0.48 -0.57,-0.67 -0.39,-0.86 -0.29,-0.77 -0.19,-0.87 -0.19,-0.76 v -2.12 l 0.19,-1.25 0.1,-1 0.29,-1.25 0.86,-3.16 0.29,-1.64 0.38,-1.63 0.39,-1.54 0.38,-1.25 0.29,-0.86 0.38,-1.25 0.39,-1.54 0.29,-1.53 0.38,-1.25 0.38,-1.73 0.1,-0.86 0.38,-1.83 0.1,-0.86 -0.19,-1 -0.58,-1 -0.86,-0.87 -0.87,-0.29 -0.86,-0.09 -0.87,0.09 z"
|
||||
class="cls-1"
|
||||
id="path154-1"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 419.71,102.8 -0.48,0.67 -1.15,1.34 -1.34,1.19 -0.68,0.48 -0.86,0.48 -0.58,0.19 -1,0.57 -0.57,0.29 -0.87,0.39 -1,0.29 -0.86,0.28 -0.87,0.1 -1,0.19 -1.05,0.1 -1.16,0.19 -0.86,0.1 h -0.87 l -1.72,-0.2 -1.73,-0.09 -0.87,-0.1 -2.59,-0.57 h -0.67 l -0.48,0.38 -0.2,0.67 v 0.87 l -0.09,1 v 1.63 l 0.09,1 0.2,0.77 0.57,1.73 1,1.53 0.58,0.68 0.58,0.57 1.53,1 1,0.29 0.87,0.29 0.67,0.1 0.86,0.19 0.87,0.09 h 1 l 0.86,-0.09 2.5,-0.48 0.87,-0.2 0.86,-0.28 0.77,-0.29 1.63,-0.67 1.54,-0.77 1.34,-1.06 0.65,-0.64 0.68,-0.58 1.34,-1.24 1.15,-1.35 0.48,-0.67 0.58,-0.77 1,-1.34 0.48,-0.77 0.39,-0.77 0.48,-0.77 0.38,-0.77 0.48,-0.67 0.38,-0.77 0.39,-0.86 0.38,-0.77 0.48,-0.77 0.68,-0.48 h 0.86 l 0.86,0.58 0.48,0.86 -0.09,0.87 -0.39,0.86 -0.48,1.06 -0.57,1 -0.48,1.06 -0.58,1.06 -0.58,1 -1,1.53 -0.28,0.58 -0.58,0.86 -1,1.35 -0.58,0.67 -0.57,0.58 -0.58,0.67 -0.67,0.77 -0.29,0.38 -0.67,0.77 -0.58,0.67 -1.28,1.17 -1,0.76 -0.87,0.77 -1.53,1 -2.41,1.06 -0.76,0.29 -0.87,0.29 -1.25,0.19 -1.15,0.29 -2,0.19 -1.25,0.09 -1.05,0.1 h -0.87 l -3.45,-0.38 -0.77,-0.2 -0.87,-0.28 -0.86,-0.39 -0.77,-0.38 -1.54,-0.87 -0.76,-0.38 -0.68,-0.58 -0.67,-0.67 -0.38,-0.48 -0.68,-0.77 -0.57,-0.67 -0.48,-0.77 -0.39,-0.67 -0.38,-0.87 -0.29,-0.76 -0.29,-0.87 -0.19,-0.86 -0.19,-0.77 -0.19,-0.87 -0.29,-2.59 v -1.85 l 0.09,-0.87 v -0.86 l 0.58,-2.6 0.33,-0.77 0.19,-0.87 0.29,-0.86 0.19,-0.87 0.29,-0.86 0.38,-1.06 0.48,-1.15 0.48,-0.87 0.58,-1 0.67,-1 0.39,-0.87 0.48,-0.67 0.57,-0.67 1.44,-1.54 1.45,-1.44 0.76,-0.67 0.87,-0.67 1,-0.68 0.86,-0.67 1,-0.57 1,-0.48 0.87,-0.39 1.25,-0.58 2,-0.67 1.73,-0.38 1.25,-0.19 h 2.11 l 1.25,0.19 1.54,0.29 1.25,0.28 0.86,0.29 0.87,0.39 0.76,0.38 0.58,0.39 1.35,1.15 0.57,0.67 0.58,1 0.48,1.15 0.29,0.87 0.19,0.86 0.09,0.87 v 1.15 l -0.24,1.21 -0.1,1 -0.29,1.15 -0.38,1.15 -0.38,1 -0.29,0.48 -0.48,0.87 z M 408,91.27 l -1.35,0.58 -1.15,0.57 -0.77,0.48 -1.34,1.16 -0.87,0.86 -0.77,0.87 -0.67,0.76 -0.67,1.06 -0.67,1 -0.48,0.86 -0.58,1.06 -0.38,1.15 -0.39,0.87 -0.29,0.77 -0.09,0.67 0.48,0.48 1.25,0.29 1.92,0.09 1,0.1 h 2.69 l 1,-0.19 0.86,-0.19 1,-0.39 0.58,-0.19 1,-0.48 0.67,-0.48 0.67,-0.58 0.68,-0.67 0.67,-0.58 0.57,-0.76 0.48,-0.68 0.39,-0.77 0.48,-0.86 0.58,-1.73 0.19,-1 0.09,-0.86 -0.09,-1 -0.39,-0.87 -0.57,-0.67 -0.77,-0.58 -0.87,-0.38 -0.86,-0.19 -0.86,-0.1 -1,0.19 z"
|
||||
class="cls-1"
|
||||
id="path156-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
</g>
|
||||
<g
|
||||
id="g176-7"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill">
|
||||
<path
|
||||
d="m 72.42,188.94 v 5.5 c 0,15.85 -4.84,26 -15.63,31 13,5.06 18.05,16.73 18.05,33 v 12.55 c 0,23.77 -12.55,36.54 -36.76,36.54 H 0 V 153.5 h 36.54 c 25.09,0 35.88,11.67 35.88,35.44 z M 24.21,175.51 v 40.72 h 9.47 c 9,0 14.53,-4 14.53,-16.28 v -8.59 c 0,-11 -3.75,-15.85 -12.33,-15.85 z m 0,62.74 v 47.32 h 13.87 c 8.15,0 12.55,-3.74 12.55,-15.18 V 257 c 0,-14.31 -4.62,-18.71 -15.63,-18.71 z"
|
||||
class="cls-1"
|
||||
id="path160-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 87.39,190.48 c 0,-24.65 13,-38.74 36.76,-38.74 23.76,0 36.76,14.09 36.76,38.74 v 80.13 c 0,24.65 -13,38.74 -36.76,38.74 -23.76,0 -36.76,-14.09 -36.76,-38.74 z m 24.21,81.67 c 0,11 4.84,15.19 12.55,15.19 7.71,0 12.55,-4.19 12.55,-15.19 v -83.21 c 0,-11 -4.85,-15.19 -12.55,-15.19 -7.7,0 -12.55,4.18 -12.55,15.19 z"
|
||||
class="cls-1"
|
||||
id="path162-7"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 199.65,153.5 v 118.87 c 0,11 4.85,15 12.55,15 7.7,0 12.55,-4 12.55,-15 V 153.5 h 22.89 v 117.33 c 0,24.65 -12.33,38.74 -36.1,38.74 -23.77,0 -36.1,-14.09 -36.1,-38.74 V 153.5 Z"
|
||||
class="cls-1"
|
||||
id="path164-7"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="M 257.11,153.5 H 332 v 22 H 306.63 V 307.59 H 282.42 V 175.51 h -25.31 z"
|
||||
class="cls-1"
|
||||
id="path166-4"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 342.51,153.5 h 24.21 v 154.09 h -24.21 z"
|
||||
class="cls-1"
|
||||
id="path168-4"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 382.35,190.48 c 0,-24.65 13,-38.74 36.76,-38.74 23.76,0 36.76,14.09 36.76,38.74 v 80.13 c 0,8.58 -1.54,15.84 -4.62,21.57 1.1,2.86 2.86,3.3 6.82,3.3 h 2.21 v 21.57 H 457 c -10.78,0 -17.61,-4 -20.91,-10.56 a 49.64,49.64 0 0 1 -16.95,2.86 c -23.77,0 -36.76,-14.09 -36.76,-38.74 z m 24.22,81.67 c 0,11 4.84,15.19 12.54,15.19 7.7,0 12.55,-4.19 12.55,-15.19 v -83.21 c 0,-11 -4.84,-15.19 -12.55,-15.19 -7.71,0 -12.54,4.18 -12.54,15.19 z"
|
||||
class="cls-1"
|
||||
id="path170-9"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 494.62,153.5 v 118.87 c 0,11 4.84,15 12.54,15 7.7,0 12.55,-4 12.55,-15 V 153.5 h 22.89 v 117.33 c 0,24.65 -12.32,38.74 -36.1,38.74 -23.78,0 -36.1,-14.09 -36.1,-38.74 V 153.5 Z"
|
||||
class="cls-1"
|
||||
id="path172-1"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 583.33,218.44 h 33.23 v 22 h -33.23 v 45.12 h 41.82 v 22 h -66 V 153.5 h 66 v 22 h -41.82 z"
|
||||
class="cls-1"
|
||||
id="path174-5"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
</g>
|
||||
<g
|
||||
id="g194-5"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill">
|
||||
<path
|
||||
d="m 232.18,435.63 c 2.18,1.13 3.27,1.67 5.48,2.72 l -1.53,3.24 c -2.26,-1.07 -3.38,-1.62 -5.6,-2.77 l -3.38,6.53 c 2.92,1.51 4.39,2.23 7.35,3.6 l -1.5,3.26 c -4.76,-2.21 -7.12,-3.4 -11.76,-5.95 l 12.09,-22 c 4,2.22 6.08,3.25 10.21,5.16 l -1.5,3.26 c -2.68,-1.24 -4,-1.89 -6.64,-3.25 -1.3,2.47 -1.94,3.72 -3.22,6.2 z"
|
||||
class="cls-1"
|
||||
id="path178-5"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 258.19,435.05 c 3.8,1.33 5.09,4.1 3.9,7.94 l -0.23,0.75 c -1.55,-0.48 -2.33,-0.73 -3.87,-1.25 l 0.33,-1 c 0.57,-1.7 0.06,-2.58 -1.21,-3 -1.27,-0.42 -2.22,-0.07 -2.83,1.62 -1.77,4.85 6.11,8.53 4,15.38 -1.19,3.84 -4.2,5.36 -8.62,3.8 -4.42,-1.56 -5.85,-4.62 -4.38,-8.36 l 0.57,-1.44 c 1.62,0.64 2.43,0.95 4.06,1.55 l -0.62,1.68 a 2.4,2.4 0 1 0 4.52,1.59 c 1.65,-4.89 -6.25,-8.81 -3.63,-15.49 1.46,-3.76 4.22,-5.1 8.01,-3.77 z"
|
||||
class="cls-1"
|
||||
id="path180-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 272.24,439.58 c 4.93,1.25 7.42,1.77 12.41,2.63 -0.24,1.42 -0.37,2.12 -0.61,3.54 -1.73,-0.3 -2.59,-0.46 -4.31,-0.8 l -4.2,21.11 c -1.86,-0.37 -2.78,-0.56 -4.63,-1 l 4.73,-21 c -1.71,-0.39 -2.56,-0.59 -4.27,-1 z"
|
||||
class="cls-1"
|
||||
id="path182-2"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 293.27,464.91 -0.4,3.78 c -1.8,-0.19 -2.71,-0.3 -4.51,-0.53 0.2,-1.51 0.29,-2.26 0.49,-3.77 1.76,0.23 2.65,0.33 4.42,0.52 z"
|
||||
class="cls-1"
|
||||
id="path184-9"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 325.25,447.42 c -1.32,0.1 -2.11,0.83 -2,2.62 0.07,1.07 0.1,1.61 0.17,2.69 -1.62,0.1 -2.43,0.14 -4.06,0.2 0,-1 -0.05,-1.46 -0.09,-2.43 -0.16,-4 1.83,-6.36 5.82,-6.66 3.99,-0.3 6.32,1.7 6.77,5.69 0.89,7.85 -7.71,11.56 -7.44,15.68 a 2.33,2.33 0 0 0 0.07,0.53 c 3.49,-0.22 5.23,-0.37 8.71,-0.76 0.16,1.43 0.24,2.14 0.39,3.57 -5.41,0.6 -8.13,0.8 -13.57,1 l -0.12,-3.09 c -0.29,-7.38 8.4,-9.16 7.76,-16.38 -0.2,-2.23 -1.09,-2.76 -2.41,-2.66 z"
|
||||
class="cls-1"
|
||||
id="path186-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 341.75,448.11 c -0.69,-3.95 1.09,-6.56 5,-7.4 3.91,-0.84 6.63,0.82 7.61,4.71 1.28,5.07 1.93,7.6 3.21,12.67 1,3.89 -0.92,6.78 -5.47,7.75 -4.55,0.97 -7.45,-0.9 -8.13,-4.86 -0.88,-5.15 -1.33,-7.72 -2.22,-12.87 z m 6.78,12.28 c 0.35,1.76 1.4,2.25 2.85,1.94 1.45,-0.31 2.2,-1.18 1.8,-2.93 -1.2,-5.29 -1.8,-7.94 -3,-13.23 -0.4,-1.75 -1.37,-2.22 -2.67,-1.95 -1.3,0.27 -2,1.11 -1.64,2.87 z"
|
||||
class="cls-1"
|
||||
id="path188-2"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 363.19,439.94 c 3.1,-1 3.31,-2.64 3.36,-4.45 1.05,-0.36 1.58,-0.55 2.63,-0.93 3.45,9.43 5.18,14.15 8.64,23.58 -1.8,0.66 -2.7,1 -4.5,1.58 -2.46,-7.26 -3.68,-10.89 -6.14,-18.15 -1.25,0.43 -1.88,0.64 -3.14,1 z"
|
||||
class="cls-1"
|
||||
id="path190-0"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 391.63,443.48 1,2.1 a 2.43,2.43 0 1 0 4.34,-2.17 c -1,-2 -1.52,-3 -2.54,-4.91 -0.82,-1.59 -1.93,-1.78 -3.2,-1.14 -1.27,0.64 -1.79,1.63 -1,3.25 l 0.33,0.68 c -1.55,0.75 -2.33,1.11 -3.89,1.82 -2.11,-5.44 -3.18,-8.15 -5.34,-13.57 4,-1.84 6,-2.83 9.88,-5 0.69,1.26 1,1.89 1.71,3.15 -2.62,1.43 -3.95,2.12 -6.62,3.42 0.93,2.21 1.39,3.32 2.31,5.54 a 4.45,4.45 0 0 1 2.62,-3.36 c 2.88,-1.47 5.3,-0.5 7.08,2.74 L 401,441 c 1.94,3.52 0.89,6.76 -3.36,8.89 -4.25,2.13 -7.45,1 -9.11,-2.64 l -0.86,-1.89 c 1.6,-0.75 2.39,-1.12 3.96,-1.88 z"
|
||||
class="cls-1"
|
||||
id="path192-7"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g202-4"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
transform="matrix(0.97135514,0,0,0.96186606,8.9536653,8.9521229)">
|
||||
<g
|
||||
id="g208-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill">
|
||||
<path
|
||||
d="M 518.64,127 A 231.92,231.92 0 0 0 493.72,88 l -7.85,6.19 a 222.67,222.67 0 0 1 21.5,32.81 z"
|
||||
class="cls-1"
|
||||
id="path204-3"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<path
|
||||
d="m 507.32,334 q -1.47,2.76 -3,5.47 a 222,222 0 0 1 -75.87,78.67 l 5.27,8.5 A 232.17,232.17 0 0 0 513,344.42 q 2.93,-5.15 5.58,-10.42 z"
|
||||
class="cls-1"
|
||||
id="path206-0"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
</g>
|
||||
<polygon
|
||||
points="81.35,17.5 144.56,127 167.66,127 115.99,37.5 509.16,37.5 457.49,127 480.58,127 543.8,17.5 "
|
||||
class="cls-1"
|
||||
id="polygon198-8"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
<polygon
|
||||
points="287.17,334 264.08,334 312.57,418 361.07,334 337.98,334 312.57,378 "
|
||||
class="cls-1"
|
||||
id="polygon200-5"
|
||||
style="fill:#570d2d;fill-opacity:1;stroke:#ffffff;stroke-width:1.78359292;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<metadata
|
||||
id="metadata4195">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:title>Hipster</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 30 KiB |
1
src/frontend/static/icons/Hipster_InstagramIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 48 48"><defs><style>.cls-1{fill:#111}</style></defs><title>Hipster</title><g><path d="M17.6,17.76H16.38v5h1c.95,0,1.53-.41,1.53-1.71V19.42C18.9,18.26,18.51,17.76,17.6,17.76Z" class="cls-1"/><path d="M24,0A24,24,0,1,0,48,24,24,24,0,0,0,24,0ZM12.2,20.9H9.8V19.16c0-1.15-.51-1.59-1.32-1.59S7.16,18,7.16,19.16V27.9c0,1.15.51,1.57,1.32,1.57S9.8,29.05,9.8,27.9v-3H8.64V22.61H12.2v5.13c0,2.58-1.29,4.06-3.79,4.06s-3.79-1.48-3.79-4.06V19.33c0-2.59,1.3-4.07,3.79-4.07s3.79,1.48,3.79,4.07Zm7,10.72a5,5,0,0,1-.23-2V27.09c0-1.5-.51-2.06-1.66-2.06h-.88v6.59H13.84V15.45h3.83c2.64,0,3.77,1.22,3.77,3.71v1.28c0,1.66-.53,2.74-1.66,3.28,1.27.53,1.68,1.75,1.68,3.44v2.49a4.68,4.68,0,0,0,.28,2Zm9.63,0-.44-2.94H25.22l-.43,2.94H22.45L25,15.45h3.72l2.59,16.17Zm12,0V20L39,31.62h-2.4L34.72,20.18V31.62H32.5V15.45H36L37.9,26.93l1.74-11.48h3.53V31.62Z" class="cls-1"/><polygon points="25.55 26.49 28 26.49 26.77 18.31 25.55 26.49" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1018 B |
1
src/frontend/static/icons/Hipster_KitchenwareOffer.svg
Normal file
|
After Width: | Height: | Size: 20 KiB |
149
src/frontend/static/icons/Hipster_NavLogo.svg
Normal file
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
inkscape:version="1.2 (dc2aeda, 2022-05-15)"
|
||||
sodipodi:docname="Hipster_NavLogo.svg"
|
||||
version="1.1"
|
||||
viewBox="0 0 316.16 60"
|
||||
data-name="Layer 1"
|
||||
id="Layer_1"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata
|
||||
id="metadata1078">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title>Hipster</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
inkscape:current-layer="g1073"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="0"
|
||||
inkscape:cy="-23.534382"
|
||||
inkscape:cx="158.74607"
|
||||
inkscape:zoom="2.2520243"
|
||||
showgrid="false"
|
||||
id="namedview1076"
|
||||
inkscape:window-height="622"
|
||||
inkscape:window-width="1370"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
guidetolerance="10"
|
||||
gridtolerance="10"
|
||||
objecttolerance="10"
|
||||
borderopacity="1"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ff54ff"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#ff54ff" />
|
||||
<defs
|
||||
id="defs1033">
|
||||
<style
|
||||
id="style1031">.cls-1{fill:#4cc8c6}.cls-2{fill:#fff}</style>
|
||||
</defs>
|
||||
<title
|
||||
id="title1035">Hipster</title>
|
||||
<g
|
||||
id="g1073">
|
||||
<g
|
||||
id="g1041"
|
||||
style="fill:#fe9a9b;fill-opacity:1">
|
||||
<path
|
||||
id="path1037"
|
||||
class="cls-1"
|
||||
d="M28.65,5.77A22.07,22.07,0,1,1,6.58,27.84,22.09,22.09,0,0,1,28.65,5.77m0-5.77A27.84,27.84,0,1,0,56.48,27.84,27.83,27.83,0,0,0,28.65,0Z"
|
||||
style="fill:#fe9a9b;fill-opacity:1" />
|
||||
<path
|
||||
id="path1039"
|
||||
class="cls-1"
|
||||
d="M47.3,16.15,28.65,48.46,10,16.15H47.3m10-5.77H0L28.65,60,57.29,10.38Z"
|
||||
style="fill:#fe9a9b;fill-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
id="g1071"
|
||||
style="fill:#570d2d;fill-opacity:1">
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1043"
|
||||
class="cls-2"
|
||||
d="M76.11,20.82c0-5.61,3-8.82,8.38-8.82s8.37,3.21,8.37,8.82V39.08c0,5.61-3,8.82-8.37,8.82s-8.38-3.21-8.38-8.82Zm5.52,18.61c0,2.5,1.1,3.46,2.86,3.46s2.85-1,2.85-3.46v-19c0-2.5-1.1-3.46-2.85-3.46s-2.86,1-2.86,3.46Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1045"
|
||||
class="cls-2"
|
||||
d="M101.33,22.08V47.5h-5V12.4h6.92l5.66,21v-21h4.92V47.5H108.2Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1047"
|
||||
class="cls-2"
|
||||
d="M117.82,12.4h5.52V42.48h9.08v5h-14.6Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1049"
|
||||
class="cls-2"
|
||||
d="M134.82,12.4h5.52V47.5h-5.52Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1051"
|
||||
class="cls-2"
|
||||
d="M149.26,22.08V47.5h-5V12.4h6.92l5.66,21v-21h4.91V47.5h-5.66Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1053"
|
||||
class="cls-2"
|
||||
d="M171.27,27.19h7.57v5h-7.57V42.48h9.53v5H165.75V12.4H180.8v5h-9.53Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1055"
|
||||
class="cls-2"
|
||||
d="M191.22,12.4c5.12,0,6.77,2.56,6.77,7.27v2.26c0,3.91-1.05,6.27-5,6.87,4.16.6,5.72,3.46,5.72,7.52v3.11c0,5-2.11,8.07-7.42,8.07H184.2V12.4Zm-1.35,16c4.86,0,7-1.16,7-6.27V19.72c0-4.11-1.25-6.27-5.67-6.27h-5.91v14.9Zm1.41,18.1c4.61,0,6.31-2.61,6.31-7V36.27c0-5.07-2.4-6.92-7.22-6.92h-5.06v17.1Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1057"
|
||||
class="cls-2"
|
||||
d="M202.55,21c0-5.81,2.56-8.87,7.43-8.87s7.52,3.06,7.52,8.87V38.87c0,5.82-2.56,8.88-7.52,8.88s-7.43-3.06-7.43-8.88Zm1.11,17.91c0,5.16,2.15,7.82,6.32,7.82s6.41-2.66,6.41-7.82V21c0-5.17-2.2-7.83-6.41-7.83s-6.32,2.66-6.32,7.83Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1059"
|
||||
class="cls-2"
|
||||
d="M222.51,38.87c0,4.22,1.5,7.88,6.17,7.88s6.16-3.66,6.16-7.88V12.4h1.06V38.82c0,4.82-1.91,8.93-7.22,8.93s-7.27-4.11-7.27-8.93V12.4h1.1Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1061"
|
||||
class="cls-2"
|
||||
d="M246.63,47.5v-34H239v-1H255.4v1h-7.67V47.5Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1063"
|
||||
class="cls-2"
|
||||
d="M259.71,12.4V47.5h-1.1V12.4Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1065"
|
||||
class="cls-2"
|
||||
d="M278.66,21V38.87a10.14,10.14,0,0,1-1.9,6.47A3.33,3.33,0,0,0,280,47h.55v1H280a4.16,4.16,0,0,1-3.92-2,7,7,0,0,1-4.91,1.71c-5.36,0-7.47-4-7.47-8.88V21c0-4.91,2.11-8.87,7.47-8.87S278.66,16.11,278.66,21Zm-13.89,0v18c0,4.31,1.71,7.82,6.37,7.82s6.42-3.51,6.42-7.82V21c0-4.32-1.76-7.83-6.42-7.83S264.77,16.66,264.77,21Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1067"
|
||||
class="cls-2"
|
||||
d="M285.43,38.87c0,4.22,1.5,7.88,6.17,7.88s6.16-3.66,6.16-7.88V12.4h1.06V38.82c0,4.82-1.91,8.93-7.22,8.93s-7.27-4.11-7.27-8.93V12.4h1.1Z" />
|
||||
<path
|
||||
style="fill:#570d2d;fill-opacity:1"
|
||||
id="path1069"
|
||||
class="cls-2"
|
||||
d="M314.21,29.2v1H303.88V46.45h12.28v1H302.78V12.4h13.38v1H303.88V29.2Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
1
src/frontend/static/icons/Hipster_PinterestIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 48 48"><defs><style>.cls-1{fill:#111}</style></defs><title>Hipster</title><g><path d="M17.13,17.76h-1.2v5.47h1.2c.81,0,1.25-.37,1.25-1.52V19.28C18.38,18.13,17.94,17.76,17.13,17.76Z" class="cls-1"/><path d="M24,0A24,24,0,1,0,48,24,24,24,0,0,0,24,0ZM20.92,21.54c0,2.59-1.25,4-3.79,4h-1.2v6.08H13.39V15.45h3.74c2.54,0,3.79,1.4,3.79,4Zm3.83,10.08H22.21V15.45h2.54Zm9.89,0H32L28.86,19.9V31.62H26.57V15.45h3.19l2.61,9.68V15.45h2.27Z" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 539 B |
1
src/frontend/static/icons/Hipster_ProfileIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 20 20"><defs><style>.cls-1{fill:#605f64}</style></defs><title>Hipster</title><g><path d="M10,3.6A2.75,2.75,0,1,1,7.25,6.35,2.75,2.75,0,0,1,10,3.6Zm0,13a6.58,6.58,0,0,1-5.49-3c0-1.82,3.66-2.82,5.49-2.82s5.47,1,5.49,2.82A6.58,6.58,0,0,1,10,16.6Z" class="cls-1"/><path d="M10,2a8,8,0,1,1-8,8,8,8,0,0,1,8-8m0-2A10,10,0,1,0,20,10,10,10,0,0,0,10,0Z" class="cls-1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 455 B |
1
src/frontend/static/icons/Hipster_SearchIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 12 12"><defs><style>.cls-1{fill:#605f64}</style></defs><title>Hipster</title><path d="M8.58,7.55H8l-.19-.19a4.48,4.48,0,1,0-.48.48L7.55,8v.55L11,12l1-1Zm-4.12,0A3.09,3.09,0,1,1,7.55,4.46,3.09,3.09,0,0,1,4.46,7.55Z" class="cls-1"/></svg>
|
||||
|
After Width: | Height: | Size: 322 B |
1
src/frontend/static/icons/Hipster_TwitterIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 48 48"><defs><style>.cls-1{fill:#111}</style></defs><title>Hipster</title><path d="M24,0A24,24,0,1,0,48,24,24,24,0,0,0,24,0ZM15.12,17.76H12.47V31.62H9.92V17.76H7.27V15.45h7.85Zm11,13.86H22.79L21.87,23l-.93,8.62H17.45l-1.8-16.17h2.47L19.49,28.2l1.22-12.75h2.45l1.27,12.84,1.32-12.84H28Zm5.61,0H29.23V15.45h2.54Zm9-13.86H38.08V31.62H35.54V17.76H32.88V15.45h7.85Z" class="cls-1"/></svg>
|
||||
|
After Width: | Height: | Size: 469 B |