From 30ca1c95dfda776759f0506e71aaaac612719b23 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Sun, 19 Feb 2023 16:28:25 +0000 Subject: [PATCH] feat: add stock ticker app --- .dockerignore | 4 + .gitignore | 1 + Dockerfile | 22 +++++ README.md | 1 + go.mod | 3 + handlers.go | 78 ++++++++++++++++++ kubernetes/manifest.yaml | 169 +++++++++++++++++++++++++++++++++++++++ main.go | 65 +++++++++++++++ types.go | 14 ++++ 9 files changed, 357 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 go.mod create mode 100644 handlers.go create mode 100644 kubernetes/manifest.yaml create mode 100644 main.go create mode 100644 types.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69804bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +* +!*.go +!go.sum +!go.mod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06e577 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +stock-ticker diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2043b79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.19.6-alpine AS builder + +ENV CGO_ENABLED=0 +ENV GOOS=linux +ENV GOARCH=amd64 + +ADD *.go /workspace/ +ADD go.mod /workspace/ + +WORKDIR /workspace + +RUN go build -a -v -ldflags="-s -w" -o /workspace/stock-ticker . + +FROM gcr.io/distroless/static-debian11 + +COPY --from=builder /workspace/stock-ticker /stock-ticker + +USER 1000 + +ENTRYPOINT ["/stock-ticker"] + +HEALTHCHECK NONE diff --git a/README.md b/README.md new file mode 100644 index 0000000..da32df1 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Stock Ticker diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2dcfd3a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitlab.com/dananglin/stock-ticker + +go 1.19 diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..843cb7c --- /dev/null +++ b/handlers.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "net/http" +) + +func healthCheckHandleFunc(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + fmt.Fprintln(w, "I'm all good!") +} + +func stockPriceHandleFunc(apiKey, symbol string, nWeeks int) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusMethodNotAllowed) + + fmt.Fprintf(w, "ERROR: The method '%s' is not allow at this endpoint.\n", r.Method) + + return + } + + resp, err := http.Get(fmt.Sprintf(requestURLFormat, symbol, apiKey)) + if err != nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + + fmt.Fprintf(w, "ERROR: Unable to get a response from Alpha Vantage; %v\n", err) + + return + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + + var s Stock + + if err := decoder.Decode(&s); err != nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + + fmt.Fprintf(w, "ERROR: Unable to decode the response from Alpha Vantage; %v\n", err) + + return + } + + if nWeeks > len(s.TimeSeries) { + nWeeks = len(s.TimeSeries) + } + + var keys []string + + for k := range s.TimeSeries { + keys = append(keys, k) + } + + sort.Sort(sort.Reverse(sort.StringSlice(keys))) + + var b strings.Builder + + b.WriteString(fmt.Sprintf(tableFormat, s.MetaData.Symbol)) + + for i := 0; i < nWeeks; i++ { + b.WriteString(fmt.Sprintf("%s | %s\n", keys[i], s.TimeSeries[keys[i]].Close)) + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + fmt.Fprintln(w, b.String()) + } +} diff --git a/kubernetes/manifest.yaml b/kubernetes/manifest.yaml new file mode 100644 index 0000000..84d1106 --- /dev/null +++ b/kubernetes/manifest.yaml @@ -0,0 +1,169 @@ +--- +# Namespace +apiVersion: v1 +kind: Namespace +metadata: + name: stock-ticker + labels: + app: stock-ticker +--- +# ServiceAccount +apiVersion: v1 +kind: ServiceAccount +metadata: + name: stock-ticker + namespace: stock-ticker + labels: + app: stock-ticker +--- +# Ingress +apiVersion: networking.k8s.io +kind: Ingress +metadata: + name: stock-ticker + namespace: stock-ticker + labels: + app: stock-ticker +spec: + rules: + - http: + paths: + - path: "/" + pathType: Prefix + backend: + service: + name: stock-ticker + port: + number: 80 +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: stock-ticker + namespace: stock-ticker + labels: + app: stock-ticker +spec: + selector: + app: stock-ticker + port: + - protocol: TCP + port: 80 + targetPort: web +--- +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: stock-ticker + namespace: stock-ticker + labels: + app: stock-ticker +data: + SYMBOL: "MSFT" + NWEEKS: "7" +--- +# Secret +apiVersion: v1 +kind: Secret +metadata: + name: stock-ticker-api-key + namespace: stock-ticker + labels: + app: stock-ticker +type: Opaque +data: + APIKEY: QzIyN1dEOVczTFVWS1ZWOQ== +--- +# PDB +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: stock-ticker-api-key + namespace: stock-ticker + labels: + app: stock-ticker +spec: + minAvailable: 1 + selector: + matchLabels: + app: stock-ticker +--- +# Deployment +apiVersion: v1 +kind: Deployment +metadata: + name: stock-ticker + namespace: stock-ticker + labels: + app: stock-ticker +spec: + replicas: 3 + revisionHistoryLimit: 3 + selector: + matchLabels: + app: stock-ticker + template: + metadata: + labels: + app: stock-ticker + spec: + serviceAccountName: stock-ticker + securityContext: + runAsUser: 1000 + runAsNonRoot: true + runAsGroup: 1000 + containers: + - name: stock-ticker + args: + - "--address=$(WEBSERVER_HOST):$(WEBSERVER_PORT)" + env: + - name: SYMBOL + valueFrom: + configMapKeyRef: + name: stock-ticker + key: SYMBOL + - name: APIKEY + valueFrom: + secretKeyRef: + name: stock-ticker-api-key + key: APIKEY + - name: NWEEKS + valueFrom: + configMapKeyRef: + name: stock-ticker + key: NWEEKS + - name: WEBSERVER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: WEBSERVER_PORT + value: "8080" + image: "" + imagePullPolicy: Always + livenessProbe: + httpGet: + port: 8080 + path: /healthcheck + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + readinessProbe: + httpGet: + port: 8080 + path: /healthcheck + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + ports: + - containerPort: 8080 + name: web + protocol: TCP + resources: + limits: + cpu: 50m + memory: 75Mi + requests: + cpu: 25m + memory: 50Mi diff --git a/main.go b/main.go new file mode 100644 index 0000000..508690d --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "strconv" +) + +const ( + requestURLFormat string = "https://www.alphavantage.co/query?function=TIME_SERIES_WEEKLY&symbol=%s&apikey=%s" +) + +var tableFormat = ` +Symbol: %s + +Week | Closing Price +========================== +` + +func main() { + var address string + + flag.StringVar(&address, "address", "0.0.0.0:8080", "the web server's listening address") + flag.Parse() + + symbol, err := getEnvironment("SYMBOL") + if err != nil { + log.Fatalf("ERROR: %v.\n", err) + } + + apiKey, err := getEnvironment("APIKEY") + if err != nil { + log.Fatalf("ERROR: %v.\n", err) + } + + nWeeksString, err := getEnvironment("NWEEKS") + if err != nil { + log.Fatalf("ERROR: %v.\n", err) + } + + nWeeks, err := strconv.Atoi(nWeeksString) + if err != nil { + log.Fatalf("ERROR: Unable to parse the number of days from %s, %v\n", nWeeksString, err) + } + + http.HandleFunc("/healthcheck", healthCheckHandleFunc) + http.HandleFunc("/stockprice", stockPriceHandleFunc(apiKey, symbol, nWeeks)) + + log.Printf("The web server is listening on %s.\n", address) + + log.Fatal(http.ListenAndServe(address, nil)) +} + + +func getEnvironment(key string) (string, error) { + value := os.Getenv(key) + if value == "" { + return "", fmt.Errorf("the environment variable %s is not set", key) + } + + return value, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..2cb87ed --- /dev/null +++ b/types.go @@ -0,0 +1,14 @@ +package main + +type Stock struct { + MetaData MetaData `json:"Meta Data"` + TimeSeries map[string]StockInfo `json:"Weekly Time Series"` +} + +type MetaData struct { + Symbol string `json:"2. Symbol"` +} + +type StockInfo struct { + Close string `json:"4. close"` +}