104 | units += int64(nanos / nanosMod)
105 | nanos = nanos % nanosMod
106 | } else {
107 | // different sign. nanos guaranteed to not to go over the limit
108 | if units > 0 {
109 | units--
110 | nanos += nanosMod
111 | } else {
112 | units++
113 | nanos -= nanosMod
114 | }
115 | }
116 |
117 | return pb.Money{
118 | Units: units,
119 | Nanos: nanos,
120 | CurrencyCode: l.GetCurrencyCode()}, nil
121 | }
122 |
123 | // MultiplySlow is a slow multiplication operation done through adding the value
124 | // to itself n-1 times.
125 | func MultiplySlow(m pb.Money, n uint32) pb.Money {
126 | out := m
127 | for n > 1 {
128 | out = Must(Sum(out, m))
129 | n--
130 | }
131 | return out
132 | }
133 |
--------------------------------------------------------------------------------
/src/currencyservice/.dockerignore:
--------------------------------------------------------------------------------
1 | client.js
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/src/currencyservice/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/src/currencyservice/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:8-alpine as base
2 |
3 | FROM base as builder
4 |
5 | # Some packages (e.g. @google-cloud/profiler) require additional
6 | # deps for post-install scripts
7 | RUN apk add --update --no-cache \
8 | python \
9 | make \
10 | g++
11 |
12 | WORKDIR /usr/src/app
13 |
14 | COPY package*.json ./
15 |
16 | RUN npm install --only=production
17 |
18 | FROM base
19 |
20 | RUN GRPC_HEALTH_PROBE_VERSION=v0.2.0 && \
21 | wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
22 | chmod +x /bin/grpc_health_probe
23 |
24 | WORKDIR /usr/src/app
25 |
26 | COPY --from=builder /usr/src/app/node_modules ./node_modules
27 |
28 | COPY . .
29 |
30 | EXPOSE 7000
31 |
32 | ENTRYPOINT [ "node", "server.js" ]
33 |
--------------------------------------------------------------------------------
/src/currencyservice/client.js:
--------------------------------------------------------------------------------
1 | /*
2 | *
3 | * Copyright 2015 gRPC authors.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | */
18 | require('@google-cloud/trace-agent').start();
19 |
20 | const path = require('path');
21 | const grpc = require('grpc');
22 | const leftPad = require('left-pad');
23 | const pino = require('pino');
24 |
25 | const PROTO_PATH = path.join(__dirname, './proto/demo.proto');
26 | const PORT = 7000;
27 |
28 | const shopProto = grpc.load(PROTO_PATH).hipstershop;
29 | const client = new shopProto.CurrencyService(`localhost:${PORT}`,
30 | grpc.credentials.createInsecure());
31 |
32 | const logger = pino({
33 | name: 'currencyservice-client',
34 | messageKey: 'message',
35 | changeLevelName: 'severity',
36 | useLevelLabels: true
37 | });
38 |
39 | const request = {
40 | from: {
41 | currency_code: 'CHF',
42 | units: 300,
43 | nanos: 0
44 | },
45 | to_code: 'EUR'
46 | };
47 |
48 | function _moneyToString (m) {
49 | return `${m.units}.${m.nanos.toString().padStart(9,'0')} ${m.currency_code}`;
50 | }
51 |
52 | client.getSupportedCurrencies({}, (err, response) => {
53 | if (err) {
54 | logger.error(`Error in getSupportedCurrencies: ${err}`);
55 | } else {
56 | logger.info(`Currency codes: ${response.currency_codes}`);
57 | }
58 | });
59 |
60 | client.convert(request, (err, response) => {
61 | if (err) {
62 | logger.error(`Error in convert: ${err}`);
63 | } else {
64 | logger.log(`Convert: ${_moneyToString(request.from)} to ${_moneyToString(response)}`);
65 | }
66 | });
67 |
--------------------------------------------------------------------------------
/src/currencyservice/data/currency_conversion.json:
--------------------------------------------------------------------------------
1 | {
2 | "EUR": "1.0",
3 | "USD": "1.1305",
4 | "JPY": "126.40",
5 | "BGN": "1.9558",
6 | "CZK": "25.592",
7 | "DKK": "7.4609",
8 | "GBP": "0.85970",
9 | "HUF": "315.51",
10 | "PLN": "4.2996",
11 | "RON": "4.7463",
12 | "SEK": "10.5375",
13 | "CHF": "1.1360",
14 | "ISK": "136.80",
15 | "NOK": "9.8040",
16 | "HRK": "7.4210",
17 | "RUB": "74.4208",
18 | "TRY": "6.1247",
19 | "AUD": "1.6072",
20 | "BRL": "4.2682",
21 | "CAD": "1.5128",
22 | "CNY": "7.5857",
23 | "HKD": "8.8743",
24 | "IDR": "15999.40",
25 | "ILS": "4.0875",
26 | "INR": "79.4320",
27 | "KRW": "1275.05",
28 | "MXN": "21.7999",
29 | "MYR": "4.6289",
30 | "NZD": "1.6679",
31 | "PHP": "59.083",
32 | "SGD": "1.5349",
33 | "THB": "36.012",
34 | "ZAR": "16.0583"
35 | }
--------------------------------------------------------------------------------
/src/currencyservice/genproto.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2018 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | # protos are loaded dynamically for node, simply copies over the proto.
18 | mkdir -p proto
19 | cp -r ../../pb/* ./proto
20 |
--------------------------------------------------------------------------------
/src/currencyservice/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "grpc-currency-service",
3 | "version": "0.1.0",
4 | "description": "A gRPC currency conversion microservice",
5 | "repository": "https://github.com/GoogleCloudPlatform/microservices-demo",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "lint": "semistandard *.js"
9 | },
10 | "license": "Apache-2.0",
11 | "dependencies": {
12 | "@google-cloud/debug-agent": "^4.0.1",
13 | "@google-cloud/profiler": "^2.0.2",
14 | "@google-cloud/trace-agent": "4.0.1",
15 | "@grpc/proto-loader": "^0.3.0",
16 | "async": "^1.5.2",
17 | "google-protobuf": "^3.0.0",
18 | "grpc": "^1.22.2",
19 | "pino": "^5.6.2",
20 | "request": "^2.87.0",
21 | "xml2js": "^0.4.19"
22 | },
23 | "devDependencies": {
24 | "semistandard": "^12.0.1"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/currencyservice/proto/grpc/health/v1/health.proto:
--------------------------------------------------------------------------------
1 | // Copyright 2015 The gRPC Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // The canonical version of this proto can be found at
16 | // https://github.com/grpc/grpc-proto/blob/master/grpc/health/v1/health.proto
17 |
18 | syntax = "proto3";
19 |
20 | package grpc.health.v1;
21 |
22 | option csharp_namespace = "Grpc.Health.V1";
23 | option go_package = "google.golang.org/grpc/health/grpc_health_v1";
24 | option java_multiple_files = true;
25 | option java_outer_classname = "HealthProto";
26 | option java_package = "io.grpc.health.v1";
27 |
28 | message HealthCheckRequest {
29 | string service = 1;
30 | }
31 |
32 | message HealthCheckResponse {
33 | enum ServingStatus {
34 | UNKNOWN = 0;
35 | SERVING = 1;
36 | NOT_SERVING = 2;
37 | }
38 | ServingStatus status = 1;
39 | }
40 |
41 | service Health {
42 | rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
43 | }
44 |
--------------------------------------------------------------------------------
/src/currencyservice/server.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2018 Google LLC.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | if(process.env.DISABLE_PROFILER) {
18 | console.log("Profiler disabled.")
19 | }
20 | else {
21 | console.log("Profiler enabled.")
22 | require('@google-cloud/profiler').start({
23 | serviceContext: {
24 | service: 'currencyservice',
25 | version: '1.0.0'
26 | }
27 | });
28 | }
29 |
30 |
31 | if(process.env.DISABLE_TRACING) {
32 | console.log("Tracing disabled.")
33 | }
34 | else {
35 | console.log("Tracing enabled.")
36 | require('@google-cloud/trace-agent').start();
37 | }
38 |
39 | if(process.env.DISABLE_DEBUGGER) {
40 | console.log("Debugger disabled.")
41 | }
42 | else {
43 | console.log("Debugger enabled.")
44 | require('@google-cloud/debug-agent').start({
45 | serviceContext: {
46 | service: 'currencyservice',
47 | version: 'VERSION'
48 | }
49 | });
50 | }
51 |
52 | const path = require('path');
53 | const grpc = require('grpc');
54 | const pino = require('pino');
55 | const protoLoader = require('@grpc/proto-loader');
56 |
57 | const MAIN_PROTO_PATH = path.join(__dirname, './proto/demo.proto');
58 | const HEALTH_PROTO_PATH = path.join(__dirname, './proto/grpc/health/v1/health.proto');
59 |
60 | const PORT = process.env.PORT;
61 |
62 | const shopProto = _loadProto(MAIN_PROTO_PATH).hipstershop;
63 | const healthProto = _loadProto(HEALTH_PROTO_PATH).grpc.health.v1;
64 |
65 | const logger = pino({
66 | name: 'currencyservice-server',
67 | messageKey: 'message',
68 | changeLevelName: 'severity',
69 | useLevelLabels: true
70 | });
71 |
72 | /**
73 | * Helper function that loads a protobuf file.
74 | */
75 | function _loadProto (path) {
76 | const packageDefinition = protoLoader.loadSync(
77 | path,
78 | {
79 | keepCase: true,
80 | longs: String,
81 | enums: String,
82 | defaults: true,
83 | oneofs: true
84 | }
85 | );
86 | return grpc.loadPackageDefinition(packageDefinition);
87 | }
88 |
89 | /**
90 | * Helper function that gets currency data from a stored JSON file
91 | * Uses public data from European Central Bank
92 | */
93 | function _getCurrencyData (callback) {
94 | const data = require('./data/currency_conversion.json');
95 | callback(data);
96 | }
97 |
98 | /**
99 | * Helper function that handles decimal/fractional carrying
100 | */
101 | function _carry (amount) {
102 | const fractionSize = Math.pow(10, 9);
103 | amount.nanos += (amount.units % 1) * fractionSize;
104 | amount.units = Math.floor(amount.units) + Math.floor(amount.nanos / fractionSize);
105 | amount.nanos = amount.nanos % fractionSize;
106 | return amount;
107 | }
108 |
109 | /**
110 | * Lists the supported currencies
111 | */
112 | function getSupportedCurrencies (call, callback) {
113 | logger.info('Getting supported currencies...');
114 | _getCurrencyData((data) => {
115 | callback(null, {currency_codes: Object.keys(data)});
116 | });
117 | }
118 |
119 | /**
120 | * Converts between currencies
121 | */
122 | function convert (call, callback) {
123 | logger.info('received conversion request');
124 | try {
125 | _getCurrencyData((data) => {
126 | const request = call.request;
127 |
128 | // Convert: from_currency --> EUR
129 | const from = request.from;
130 | const euros = _carry({
131 | units: from.units / data[from.currency_code],
132 | nanos: from.nanos / data[from.currency_code]
133 | });
134 |
135 | euros.nanos = Math.round(euros.nanos);
136 |
137 | // Convert: EUR --> to_currency
138 | const result = _carry({
139 | units: euros.units * data[request.to_code],
140 | nanos: euros.nanos * data[request.to_code]
141 | });
142 |
143 | result.units = Math.floor(result.units);
144 | result.nanos = Math.floor(result.nanos);
145 | result.currency_code = request.to_code;
146 |
147 | logger.info(`conversion request successful`);
148 | callback(null, result);
149 | });
150 | } catch (err) {
151 | logger.error(`conversion request failed: ${err}`);
152 | callback(err.message);
153 | }
154 | }
155 |
156 | /**
157 | * Endpoint for health checks
158 | */
159 | function check (call, callback) {
160 | callback(null, { status: 'SERVING' });
161 | }
162 |
163 | /**
164 | * Starts an RPC server that receives requests for the
165 | * CurrencyConverter service at the sample server port
166 | */
167 | function main () {
168 | logger.info(`Starting gRPC server on port ${PORT}...`);
169 | const server = new grpc.Server();
170 | server.addService(shopProto.CurrencyService.service, {getSupportedCurrencies, convert});
171 | server.addService(healthProto.Health.service, {check});
172 | server.bind(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure());
173 | server.start();
174 | }
175 |
176 | main();
177 |
--------------------------------------------------------------------------------
/src/emailservice/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.7-slim as base
2 |
3 | FROM base as builder
4 |
5 | RUN apt-get -qq update \
6 | && apt-get install -y --no-install-recommends \
7 | g++ \
8 | && rm -rf /var/lib/apt/lists/*
9 |
10 | # get packages
11 | COPY requirements.txt .
12 | RUN pip install -r requirements.txt
13 |
14 | FROM base as final
15 | # Enable unbuffered logging
16 | ENV PYTHONUNBUFFERED=1
17 | # Enable Profiler
18 | ENV ENABLE_PROFILER=1
19 |
20 | RUN apt-get -qq update \
21 | && apt-get install -y --no-install-recommends \
22 | wget
23 |
24 | # Download the grpc health probe
25 | RUN GRPC_HEALTH_PROBE_VERSION=v0.2.0 && \
26 | wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
27 | chmod +x /bin/grpc_health_probe
28 |
29 | WORKDIR /email_server
30 |
31 | # Grab packages from builder
32 | COPY --from=builder /usr/local/lib/python3.7/ /usr/local/lib/python3.7/
33 |
34 | # Add the application
35 | COPY . .
36 |
37 | EXPOSE 8080
38 | ENTRYPOINT [ "python", "email_server.py" ]
39 |
--------------------------------------------------------------------------------
/src/emailservice/email_client.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | #
3 | # Copyright 2018 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | import grpc
18 |
19 | import demo_pb2
20 | import demo_pb2_grpc
21 |
22 | from logger import getJSONLogger
23 | logger = getJSONLogger('emailservice-client')
24 |
25 | from opencensus.trace.tracer import Tracer
26 | from opencensus.trace.exporters import stackdriver_exporter
27 | from opencensus.trace.ext.grpc import client_interceptor
28 |
29 | try:
30 | exporter = stackdriver_exporter.StackdriverExporter()
31 | tracer = Tracer(exporter=exporter)
32 | tracer_interceptor = client_interceptor.OpenCensusClientInterceptor(tracer, host_port='0.0.0.0:8080')
33 | except:
34 | tracer_interceptor = client_interceptor.OpenCensusClientInterceptor()
35 |
36 | def send_confirmation_email(email, order):
37 | channel = grpc.insecure_channel('0.0.0.0:8080')
38 | channel = grpc.intercept_channel(channel, tracer_interceptor)
39 | stub = demo_pb2_grpc.EmailServiceStub(channel)
40 | try:
41 | response = stub.SendOrderConfirmation(demo_pb2.SendOrderConfirmationRequest(
42 | email = email,
43 | order = order
44 | ))
45 | logger.info('Request sent.')
46 | except grpc.RpcError as err:
47 | logger.error(err.details())
48 | logger.error('{}, {}'.format(err.code().name, err.code().value))
49 |
50 | if __name__ == '__main__':
51 | logger.info('Client for email service.')
52 |
--------------------------------------------------------------------------------
/src/emailservice/genproto.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2018 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | #!/bin/bash -e
18 |
19 | python -m grpc_tools.protoc -I../../pb --python_out=. --grpc_python_out=. ../../pb/demo.proto
20 |
--------------------------------------------------------------------------------
/src/emailservice/logger.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | #
3 | # Copyright 2018 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | import logging
18 | import sys
19 | from pythonjsonlogger import jsonlogger
20 |
21 | # TODO(yoshifumi) this class is duplicated since other Python services are
22 | # not sharing the modules for logging.
23 | class CustomJsonFormatter(jsonlogger.JsonFormatter):
24 | def add_fields(self, log_record, record, message_dict):
25 | super(CustomJsonFormatter, self).add_fields(log_record, record, message_dict)
26 | if not log_record.get('timestamp'):
27 | log_record['timestamp'] = record.created
28 | if log_record.get('severity'):
29 | log_record['severity'] = log_record['severity'].upper()
30 | else:
31 | log_record['severity'] = record.levelname
32 |
33 | def getJSONLogger(name):
34 | logger = logging.getLogger(name)
35 | handler = logging.StreamHandler(sys.stdout)
36 | formatter = CustomJsonFormatter('(timestamp) (severity) (name) (message)')
37 | handler.setFormatter(formatter)
38 | logger.addHandler(handler)
39 | logger.setLevel(logging.INFO)
40 | logger.propagate = False
41 | return logger
42 |
--------------------------------------------------------------------------------
/src/emailservice/requirements.in:
--------------------------------------------------------------------------------
1 | google-api-core==1.6.0
2 | grpcio-health-checking==1.12.1
3 | grpcio==1.16.1
4 | jinja2==2.10
5 | opencensus[stackdriver]==0.1.10
6 | python-json-logger==0.1.9
7 | google-cloud-profiler==1.0.8
8 |
--------------------------------------------------------------------------------
/src/emailservice/requirements.txt:
--------------------------------------------------------------------------------
1 | #
2 | # This file is autogenerated by pip-compile
3 | # To update, run:
4 | #
5 | # pip-compile --output-file requirements.txt requirements.in
6 | #
7 | cachetools==3.0.0 # via google-auth
8 | certifi==2018.11.29 # via requests
9 | chardet==3.0.4 # via requests
10 | google-api-core[grpc]==1.6.0
11 | google-api-python-client==1.7.8 # via google-cloud-profiler
12 | google-auth-httplib2==0.0.3 # via google-api-python-client, google-cloud-profiler
13 | google-auth==1.6.2 # via google-api-core, google-api-python-client, google-auth-httplib2, google-cloud-profiler
14 | google-cloud-core==0.29.1 # via google-cloud-trace
15 | google-cloud-profiler==1.0.8
16 | google-cloud-trace==0.20.2 # via opencensus
17 | googleapis-common-protos==1.5.5 # via google-api-core
18 | grpcio-health-checking==1.12.1
19 | grpcio==1.16.1
20 | httplib2==0.12.1 # via google-api-python-client, google-auth-httplib2
21 | idna==2.8 # via requests
22 | jinja2==2.10
23 | markupsafe==1.1.0 # via jinja2
24 | opencensus[stackdriver]==0.1.10
25 | protobuf==3.6.1 # via google-api-core, google-cloud-profiler, googleapis-common-protos, grpcio-health-checking
26 | pyasn1-modules==0.2.3 # via google-auth
27 | pyasn1==0.4.5 # via pyasn1-modules, rsa
28 | python-json-logger==0.1.9
29 | pytz==2018.9 # via google-api-core
30 | requests==2.21.0 # via google-api-core, google-cloud-profiler
31 | rsa==4.0 # via google-auth
32 | six==1.12.0 # via google-api-core, google-api-python-client, google-auth, grpcio, protobuf
33 | uritemplate==3.0.0 # via google-api-python-client
34 | urllib3==1.24.1 # via requests
35 |
--------------------------------------------------------------------------------
/src/emailservice/templates/confirmation.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Your Order Confirmation
5 |
6 |
7 |
12 |
13 | Your Order Confirmation
14 | Thanks for shopping with us!
15 |
Order ID
16 | #{{ order.order_id }}
17 | Shipping
18 | #{{ order.shipping_tracking_id }}
19 | {{ order.shipping_cost.units }}. {{ "%02d" | format(order.shipping_cost.nanos // 10000000) }} {{ order.shipping_cost.currency_code }}
20 | {{ 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}}
21 | Items
22 |
23 |
24 | Item No. |
25 | Quantity |
26 | Price |
27 |
28 | {% for item in order.items %}
29 |
30 | #{{ item.item.product_id }} |
31 | {{ item.item.quantity }} |
32 | {{ item.cost.units }}.{{ "%02d" | format(item.cost.nanos // 10000000) }} {{ item.cost.currency_code }} |
33 |
34 | {% endfor %}
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | vendor/
2 |
--------------------------------------------------------------------------------
/src/frontend/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/.gitkeep
--------------------------------------------------------------------------------
/src/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.12-alpine as builder
2 | RUN apk add --no-cache ca-certificates git && \
3 | wget -qO/go/bin/dep https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64 && \
4 | chmod +x /go/bin/dep
5 |
6 | ENV PROJECT github.com/GoogleCloudPlatform/microservices-demo/src/frontend
7 | WORKDIR /go/src/$PROJECT
8 |
9 | # restore dependencies
10 | COPY Gopkg.* ./
11 | RUN dep ensure --vendor-only -v
12 | COPY . .
13 | RUN go install .
14 |
15 | FROM alpine as release
16 | RUN apk add --no-cache ca-certificates \
17 | busybox-extras net-tools bind-tools
18 | WORKDIR /frontend
19 | COPY --from=builder /go/bin/frontend /frontend/server
20 | COPY ./templates ./templates
21 | COPY ./static ./static
22 | EXPOSE 8080
23 | ENTRYPOINT ["/frontend/server"]
24 |
--------------------------------------------------------------------------------
/src/frontend/Gopkg.toml:
--------------------------------------------------------------------------------
1 | # Gopkg.toml example
2 | #
3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
4 | # for detailed Gopkg.toml documentation.
5 | #
6 | # required = ["github.com/user/thing/cmd/thing"]
7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
8 | #
9 | # [[constraint]]
10 | # name = "github.com/user/project"
11 | # version = "1.0.0"
12 | #
13 | # [[constraint]]
14 | # name = "github.com/user/project2"
15 | # branch = "dev"
16 | # source = "github.com/myfork/project2"
17 | #
18 | # [[override]]
19 | # name = "github.com/x/y"
20 | # version = "2.4.0"
21 | #
22 | # [prune]
23 | # non-go = false
24 | # go-tests = true
25 | # unused-packages = true
26 |
27 |
28 | [[constraint]]
29 | name = "cloud.google.com/go"
30 | version = "0.40.0"
31 |
32 | [[constraint]]
33 | name = "contrib.go.opencensus.io/exporter/stackdriver"
34 | version = "0.5.0"
35 |
36 | [[constraint]]
37 | name = "github.com/golang/protobuf"
38 | version = "1.2.0"
39 |
40 | [[constraint]]
41 | name = "github.com/google/uuid"
42 | version = "1.0.0"
43 |
44 | [[constraint]]
45 | name = "github.com/gorilla/mux"
46 | version = "1.6.2"
47 |
48 | [[constraint]]
49 | name = "github.com/pkg/errors"
50 | version = "0.8.0"
51 |
52 | [[constraint]]
53 | name = "github.com/sirupsen/logrus"
54 | version = "1.0.6"
55 |
56 | [[constraint]]
57 | name = "go.opencensus.io"
58 | version = "0.16.0"
59 |
60 | [[constraint]]
61 | branch = "master"
62 | name = "golang.org/x/net"
63 |
64 | [prune]
65 | go-tests = true
66 | unused-packages = true
67 |
--------------------------------------------------------------------------------
/src/frontend/README.md:
--------------------------------------------------------------------------------
1 | # frontend
2 |
3 | Run the following command to restore dependencies to `vendor/` directory:
4 |
5 | dep ensure --vendor-only
6 |
--------------------------------------------------------------------------------
/src/frontend/genproto.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2018 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | #!/bin/bash -e
18 |
19 | PATH=$PATH:$GOPATH/bin
20 | protodir=../../pb
21 |
22 | protoc --go_out=plugins=grpc:genproto -I $protodir $protodir/demo.proto
23 |
--------------------------------------------------------------------------------
/src/frontend/middleware.go:
--------------------------------------------------------------------------------
1 | // Copyright 2018 Google LLC
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | package main
16 |
17 | import (
18 | "context"
19 | "net/http"
20 | "time"
21 |
22 | "github.com/google/uuid"
23 | "github.com/sirupsen/logrus"
24 | )
25 |
26 | type ctxKeyLog struct{}
27 | type ctxKeyRequestID struct{}
28 |
29 | type logHandler struct {
30 | log *logrus.Logger
31 | next http.Handler
32 | }
33 |
34 | type responseRecorder struct {
35 | b int
36 | status int
37 | w http.ResponseWriter
38 | }
39 |
40 | func (r *responseRecorder) Header() http.Header { return r.w.Header() }
41 |
42 | func (r *responseRecorder) Write(p []byte) (int, error) {
43 | if r.status == 0 {
44 | r.status = http.StatusOK
45 | }
46 | n, err := r.w.Write(p)
47 | r.b += n
48 | return n, err
49 | }
50 |
51 | func (r *responseRecorder) WriteHeader(statusCode int) {
52 | r.status = statusCode
53 | r.w.WriteHeader(statusCode)
54 | }
55 |
56 | func (lh *logHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
57 | ctx := r.Context()
58 | requestID, _ := uuid.NewRandom()
59 | ctx = context.WithValue(ctx, ctxKeyRequestID{}, requestID.String())
60 |
61 | start := time.Now()
62 | rr := &responseRecorder{w: w}
63 | log := lh.log.WithFields(logrus.Fields{
64 | "http.req.path": r.URL.Path,
65 | "http.req.method": r.Method,
66 | "http.req.id": requestID.String(),
67 | })
68 | if v, ok := r.Context().Value(ctxKeySessionID{}).(string); ok {
69 | log = log.WithField("session", v)
70 | }
71 | log.Debug("request started")
72 | defer func() {
73 | log.WithFields(logrus.Fields{
74 | "http.resp.took_ms": int64(time.Since(start) / time.Millisecond),
75 | "http.resp.status": rr.status,
76 | "http.resp.bytes": rr.b}).Debugf("request complete")
77 | }()
78 |
79 | ctx = context.WithValue(ctx, ctxKeyLog{}, log)
80 | r = r.WithContext(ctx)
81 | lh.next.ServeHTTP(rr, r)
82 | }
83 |
84 | func ensureSessionID(next http.Handler) http.HandlerFunc {
85 | return func(w http.ResponseWriter, r *http.Request) {
86 | var sessionID string
87 | c, err := r.Cookie(cookieSessionID)
88 | if err == http.ErrNoCookie {
89 | u, _ := uuid.NewRandom()
90 | sessionID = u.String()
91 | http.SetCookie(w, &http.Cookie{
92 | Name: cookieSessionID,
93 | Value: sessionID,
94 | MaxAge: cookieMaxAge,
95 | })
96 | } else if err != nil {
97 | return
98 | } else {
99 | sessionID = c.Value
100 | }
101 | ctx := context.WithValue(r.Context(), ctxKeySessionID{}, sessionID)
102 | r = r.WithContext(ctx)
103 | next.ServeHTTP(w, r)
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/frontend/money/money.go:
--------------------------------------------------------------------------------
1 | // Copyright 2018 Google LLC
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | package money
16 |
17 | import (
18 | "errors"
19 |
20 | pb "github.com/GoogleCloudPlatform/microservices-demo/src/frontend/genproto"
21 | )
22 |
23 | const (
24 | nanosMin = -999999999
25 | nanosMax = +999999999
26 | nanosMod = 1000000000
27 | )
28 |
29 | var (
30 | ErrInvalidValue = errors.New("one of the specified money values is invalid")
31 | ErrMismatchingCurrency = errors.New("mismatching currency codes")
32 | )
33 |
34 | // IsValid checks if specified value has a valid units/nanos signs and ranges.
35 | func IsValid(m pb.Money) bool {
36 | return signMatches(m) && validNanos(m.GetNanos())
37 | }
38 |
39 | func signMatches(m pb.Money) bool {
40 | return m.GetNanos() == 0 || m.GetUnits() == 0 || (m.GetNanos() < 0) == (m.GetUnits() < 0)
41 | }
42 |
43 | func validNanos(nanos int32) bool { return nanosMin <= nanos && nanos <= nanosMax }
44 |
45 | // IsZero returns true if the specified money value is equal to zero.
46 | func IsZero(m pb.Money) bool { return m.GetUnits() == 0 && m.GetNanos() == 0 }
47 |
48 | // IsPositive returns true if the specified money value is valid and is
49 | // positive.
50 | func IsPositive(m pb.Money) bool {
51 | return IsValid(m) && m.GetUnits() > 0 || (m.GetUnits() == 0 && m.GetNanos() > 0)
52 | }
53 |
54 | // IsNegative returns true if the specified money value is valid and is
55 | // negative.
56 | func IsNegative(m pb.Money) bool {
57 | return IsValid(m) && m.GetUnits() < 0 || (m.GetUnits() == 0 && m.GetNanos() < 0)
58 | }
59 |
60 | // AreSameCurrency returns true if values l and r have a currency code and
61 | // they are the same values.
62 | func AreSameCurrency(l, r pb.Money) bool {
63 | return l.GetCurrencyCode() == r.GetCurrencyCode() && l.GetCurrencyCode() != ""
64 | }
65 |
66 | // AreEquals returns true if values l and r are the equal, including the
67 | // currency. This does not check validity of the provided values.
68 | func AreEquals(l, r pb.Money) bool {
69 | return l.GetCurrencyCode() == r.GetCurrencyCode() &&
70 | l.GetUnits() == r.GetUnits() && l.GetNanos() == r.GetNanos()
71 | }
72 |
73 | // Negate returns the same amount with the sign negated.
74 | func Negate(m pb.Money) pb.Money {
75 | return pb.Money{
76 | Units: -m.GetUnits(),
77 | Nanos: -m.GetNanos(),
78 | CurrencyCode: m.GetCurrencyCode()}
79 | }
80 |
81 | // Must panics if the given error is not nil. This can be used with other
82 | // functions like: "m := Must(Sum(a,b))".
83 | func Must(v pb.Money, err error) pb.Money {
84 | if err != nil {
85 | panic(err)
86 | }
87 | return v
88 | }
89 |
90 | // Sum adds two values. Returns an error if one of the values are invalid or
91 | // currency codes are not matching (unless currency code is unspecified for
92 | // both).
93 | func Sum(l, r pb.Money) (pb.Money, error) {
94 | if !IsValid(l) || !IsValid(r) {
95 | return pb.Money{}, ErrInvalidValue
96 | } else if l.GetCurrencyCode() != r.GetCurrencyCode() {
97 | return pb.Money{}, ErrMismatchingCurrency
98 | }
99 | units := l.GetUnits() + r.GetUnits()
100 | nanos := l.GetNanos() + r.GetNanos()
101 |
102 | if (units == 0 && nanos == 0) || (units > 0 && nanos >= 0) || (units < 0 && nanos <= 0) {
103 | // same sign
104 | units += int64(nanos / nanosMod)
105 | nanos = nanos % nanosMod
106 | } else {
107 | // different sign. nanos guaranteed to not to go over the limit
108 | if units > 0 {
109 | units--
110 | nanos += nanosMod
111 | } else {
112 | units++
113 | nanos -= nanosMod
114 | }
115 | }
116 |
117 | return pb.Money{
118 | Units: units,
119 | Nanos: nanos,
120 | CurrencyCode: l.GetCurrencyCode()}, nil
121 | }
122 |
123 | // MultiplySlow is a slow multiplication operation done through adding the value
124 | // to itself n-1 times.
125 | func MultiplySlow(m pb.Money, n uint32) pb.Money {
126 | out := m
127 | for n > 1 {
128 | out = Must(Sum(out, m))
129 | n--
130 | }
131 | return out
132 | }
133 |
--------------------------------------------------------------------------------
/src/frontend/rpc.go:
--------------------------------------------------------------------------------
1 | // Copyright 2018 Google LLC
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | package main
16 |
17 | import (
18 | "context"
19 | "time"
20 |
21 | pb "github.com/GoogleCloudPlatform/microservices-demo/src/frontend/genproto"
22 |
23 | "github.com/pkg/errors"
24 | )
25 |
26 | const (
27 | avoidNoopCurrencyConversionRPC = false
28 | )
29 |
30 | func (fe *frontendServer) getCurrencies(ctx context.Context) ([]string, error) {
31 | currs, err := pb.NewCurrencyServiceClient(fe.currencySvcConn).
32 | GetSupportedCurrencies(ctx, &pb.Empty{})
33 | if err != nil {
34 | return nil, err
35 | }
36 | var out []string
37 | for _, c := range currs.CurrencyCodes {
38 | if _, ok := whitelistedCurrencies[c]; ok {
39 | out = append(out, c)
40 | }
41 | }
42 | return out, nil
43 | }
44 |
45 | func (fe *frontendServer) getProducts(ctx context.Context) ([]*pb.Product, error) {
46 | resp, err := pb.NewProductCatalogServiceClient(fe.productCatalogSvcConn).
47 | ListProducts(ctx, &pb.Empty{})
48 | return resp.GetProducts(), err
49 | }
50 |
51 | func (fe *frontendServer) getProduct(ctx context.Context, id string) (*pb.Product, error) {
52 | resp, err := pb.NewProductCatalogServiceClient(fe.productCatalogSvcConn).
53 | GetProduct(ctx, &pb.GetProductRequest{Id: id})
54 | return resp, err
55 | }
56 |
57 | func (fe *frontendServer) getCart(ctx context.Context, userID string) ([]*pb.CartItem, error) {
58 | resp, err := pb.NewCartServiceClient(fe.cartSvcConn).GetCart(ctx, &pb.GetCartRequest{UserId: userID})
59 | return resp.GetItems(), err
60 | }
61 |
62 | func (fe *frontendServer) emptyCart(ctx context.Context, userID string) error {
63 | _, err := pb.NewCartServiceClient(fe.cartSvcConn).EmptyCart(ctx, &pb.EmptyCartRequest{UserId: userID})
64 | return err
65 | }
66 |
67 | func (fe *frontendServer) insertCart(ctx context.Context, userID, productID string, quantity int32) error {
68 | _, err := pb.NewCartServiceClient(fe.cartSvcConn).AddItem(ctx, &pb.AddItemRequest{
69 | UserId: userID,
70 | Item: &pb.CartItem{
71 | ProductId: productID,
72 | Quantity: quantity},
73 | })
74 | return err
75 | }
76 |
77 | func (fe *frontendServer) convertCurrency(ctx context.Context, money *pb.Money, currency string) (*pb.Money, error) {
78 | if avoidNoopCurrencyConversionRPC && money.GetCurrencyCode() == currency {
79 | return money, nil
80 | }
81 | return pb.NewCurrencyServiceClient(fe.currencySvcConn).
82 | Convert(ctx, &pb.CurrencyConversionRequest{
83 | From: money,
84 | ToCode: currency})
85 | }
86 |
87 | func (fe *frontendServer) getShippingQuote(ctx context.Context, items []*pb.CartItem, currency string) (*pb.Money, error) {
88 | quote, err := pb.NewShippingServiceClient(fe.shippingSvcConn).GetQuote(ctx,
89 | &pb.GetQuoteRequest{
90 | Address: nil,
91 | Items: items})
92 | if err != nil {
93 | return nil, err
94 | }
95 | localized, err := fe.convertCurrency(ctx, quote.GetCostUsd(), currency)
96 | return localized, errors.Wrap(err, "failed to convert currency for shipping cost")
97 | }
98 |
99 | func (fe *frontendServer) getRecommendations(ctx context.Context, userID string, productIDs []string) ([]*pb.Product, error) {
100 | resp, err := pb.NewRecommendationServiceClient(fe.recommendationSvcConn).ListRecommendations(ctx,
101 | &pb.ListRecommendationsRequest{UserId: userID, ProductIds: productIDs})
102 | if err != nil {
103 | return nil, err
104 | }
105 | out := make([]*pb.Product, len(resp.GetProductIds()))
106 | for i, v := range resp.GetProductIds() {
107 | p, err := fe.getProduct(ctx, v)
108 | if err != nil {
109 | return nil, errors.Wrapf(err, "failed to get recommended product info (#%s)", v)
110 | }
111 | out[i] = p
112 | }
113 | if len(out) > 4 {
114 | out = out[:4] // take only first four to fit the UI
115 | }
116 | return out, err
117 | }
118 |
119 | func (fe *frontendServer) getAd(ctx context.Context, ctxKeys []string) ([]*pb.Ad, error) {
120 | ctx, cancel := context.WithTimeout(ctx, time.Millisecond*100)
121 | defer cancel()
122 |
123 | resp, err := pb.NewAdServiceClient(fe.adSvcConn).GetAds(ctx, &pb.AdRequest{
124 | ContextKeys: ctxKeys,
125 | })
126 | return resp.GetAds(), errors.Wrap(err, "failed to get ads")
127 | }
128 |
--------------------------------------------------------------------------------
/src/frontend/static/img/products/air-plant.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/air-plant.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/barista-kit.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/barista-kit.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/camera-lens.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/camera-lens.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/camp-mug.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/camp-mug.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/city-bike.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/city-bike.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/credits.txt:
--------------------------------------------------------------------------------
1 | film-camera.jpg,CC0 Public Domain,https://pxhere.com/en/photo/829555
2 | camera-lens.jpg,CC0 Public Domain,https://pxhere.com/en/photo/670041
3 | air-plant.jpg,,https://unsplash.com/photos/uUwEAW5jFLE
4 | camp-mug.jpg,,https://unsplash.com/photos/h9VhRlMfVkg
5 | record-player.jpg,,https://unsplash.com/photos/pEEHFSX1vak
6 | city-bike.jpg,,https://unsplash.com/photos/Lpe9u9etwMU
7 | typewriter.jpg,,https://unsplash.com/photos/mk7D-4UCfmg
8 | barista-kit.jpg,,https://unsplash.com/photos/ZiRyGGIpRCw
9 | terrarium.jpg,,https://unsplash.com/photos/E9QYLj0724Y
10 |
--------------------------------------------------------------------------------
/src/frontend/static/img/products/film-camera.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/film-camera.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/record-player.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/record-player.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/terrarium.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/terrarium.jpg
--------------------------------------------------------------------------------
/src/frontend/static/img/products/typewriter.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gremlin/microservices-demo/0eb6e19a9ed2e1289441fc197271b2be46cf671e/src/frontend/static/img/products/typewriter.jpg
--------------------------------------------------------------------------------
/src/frontend/templates/ad.html:
--------------------------------------------------------------------------------
1 | {{ define "text_ad" }}
2 |
10 | {{ end }}
--------------------------------------------------------------------------------
/src/frontend/templates/error.html:
--------------------------------------------------------------------------------
1 | {{ define "error" }}
2 | {{ template "header" . }}
3 |
4 |
5 |
6 |
7 |
Uh, oh!
8 |
Something has failed. Below are some details for debugging.
9 |
10 |
HTTP Status: {{.status_code}} {{.status}}
11 |
13 | {{- .error -}}
14 |
15 |
16 |
17 |
18 |
19 | {{ template "footer" . }}
20 | {{ end }}
21 |
--------------------------------------------------------------------------------
/src/frontend/templates/footer.html:
--------------------------------------------------------------------------------
1 | {{ define "footer" }}
2 |
22 |
23 |