Implement GCP Cloud Function

This commit is contained in:
Ray Miller 2023-10-08 14:25:57 +01:00
parent 794b873ddd
commit 15d4b66afa
13 changed files with 242686 additions and 38 deletions

20
README.md Normal file
View file

@ -0,0 +1,20 @@
# Puzzle Solver
Match patterns and solve anagrams - handy for crossword fanatics.
## Standalone Server
```bash
go run main.go
```
## Cloud Function
To test using the Cloud Functions Framework:
```bash
env FUNCTION_TARGET=WordSearch WORDLIST_BUCKET=word-search-1729-assets \
WORDLIST_PATH=data/wordlist.txt LOCAL_ONLY=true go run cmd/main.go
curl 'http://localhost:8080?mode=anagrams&pattern=idea'
```

View file

@ -10,6 +10,18 @@ import (
type DB interface { type DB interface {
FindAnagrams(s string) []string FindAnagrams(s string) []string
Add(s string)
}
type HashDBImpl map[string][]string
func New() HashDBImpl {
return make(HashDBImpl)
}
func (db HashDBImpl) Add(s string) {
k := toKey(s)
db[k] = append(db[k], s)
} }
func toKey(s string) string { func toKey(s string) string {
@ -18,15 +30,11 @@ func toKey(s string) string {
return string(xs) return string(xs)
} }
type HashDBImpl map[string][]string
func Load(r io.Reader) (DB, error) { func Load(r io.Reader) (DB, error) {
db := make(HashDBImpl) db := New()
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
for sc.Scan() { for sc.Scan() {
s := sc.Text() db.Add(sc.Text())
k := toKey(s)
db[k] = append(db[k], s)
} }
if err := sc.Err(); err != nil { if err := sc.Err(); err != nil {

View file

@ -3,10 +3,11 @@ div.center {
} }
div#results { div#results {
overflow-y: auto; height: 60dvh;
max-height: 70dvh;
} }
div#results > ul { div#results > ul {
list-style-type: none; list-style-type: none;
overflow-y: auto;
height: 100%;
} }

141
cloudfn/cloudfn.go Normal file
View file

@ -0,0 +1,141 @@
package cloudfn
import (
"bufio"
"context"
"fmt"
"log"
"net/http"
"os"
"sort"
"text/template"
"cloud.google.com/go/storage"
"github.com/GoogleCloudPlatform/functions-framework-go/functions"
"github.com/ray1729/puzzle-solver/anagram"
"github.com/ray1729/puzzle-solver/match"
"github.com/rs/cors"
)
var anagramDB anagram.DB
var matchDB match.DB
func initializeDB(ctx context.Context, bucketName, objectName string) error {
client, err := storage.NewClient(ctx)
if err != nil {
return fmt.Errorf("error creating storage client: %v", err)
}
r, err := client.Bucket(bucketName).Object(objectName).NewReader(ctx)
if err != nil {
return fmt.Errorf("error opening gs://%s/%s: %v", bucketName, objectName, err)
}
defer r.Close()
anagramDB = anagram.New()
matchDB = match.New()
sc := bufio.NewScanner(r)
for sc.Scan() {
s := sc.Text()
anagramDB.Add(s)
matchDB.Add(s)
}
if err := sc.Err(); err != nil {
return fmt.Errorf("error reading gs://%s/%s: %v", bucketName, objectName, err)
}
return nil
}
func init() {
ctx := context.Background()
bucketName := mustGetenv("WORDLIST_BUCKET")
objectName := mustGetenv("WORDLIST_PATH")
log.Println("Initializing databases")
if err := initializeDB(ctx, bucketName, objectName); err != nil {
panic(err)
}
var corsHandler = cors.Default()
log.Println("Registering HTTP function with the Functions Framework")
functions.HTTP("WordSearch", func(w http.ResponseWriter, r *http.Request) {
corsHandler.ServeHTTP(w, r, handleFormSubmission)
})
}
func mustGetenv(s string) string {
v := os.Getenv(s)
if len(v) == 0 {
panic(fmt.Sprintf("environment variable %s not set", s))
}
return v
}
func handleFormSubmission(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
log.Printf("error parsing form: %v", err)
http.Error(w, "error parsing form", http.StatusBadRequest)
return
}
mode := r.Form.Get("mode")
pattern := r.Form.Get("pattern")
if len(pattern) == 0 {
http.Error(w, "Missing pattern", http.StatusBadRequest)
return
}
switch mode {
case "match":
results := matchResults(matchDB, pattern)
renderTemplate(w, resultsTmpl, results)
case "anagrams":
results := anagramResults(anagramDB, pattern)
renderTemplate(w, resultsTmpl, results)
default:
log.Printf("invalid mode: %s", mode)
http.Error(w, fmt.Sprintf("Invalid mode: %s", mode), http.StatusBadRequest)
}
}
func anagramResults(db anagram.DB, pattern string) ResultParams {
var params ResultParams
params.Results = db.FindAnagrams(pattern)
if len(params.Results) > 0 {
params.Preamble = fmt.Sprintf("Anagrams of %q:", pattern)
} else {
params.Preamble = fmt.Sprintf("Found no anagrams of %q", pattern)
}
sort.Slice(params.Results, func(i, j int) bool { return params.Results[i] < params.Results[j] })
return params
}
func matchResults(db match.DB, pattern string) ResultParams {
var params ResultParams
params.Results = db.FindMatches(pattern)
if len(params.Results) > 0 {
params.Preamble = fmt.Sprintf("Matches for %q:", pattern)
} else {
params.Preamble = fmt.Sprintf("Found no matches for %q", pattern)
}
sort.Slice(params.Results, func(i, j int) bool { return params.Results[i] < params.Results[j] })
return params
}
func renderTemplate(w http.ResponseWriter, t *template.Template, params any) {
err := t.Execute(w, params)
if err != nil {
log.Printf("Error rendering template %s: %v", t.Name(), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
type ResultParams struct {
Preamble string
Results []string
}
var resultsTmpl = template.Must(template.New("results").Parse(`
{{ with .Preamble }}
<p>{{ . }}</p>
{{ end }}
<ul>
{{ range .Results }}
<li>{{.}}</li>
{{ end }}
</ul>
`))

29
cmd/main.go Normal file
View file

@ -0,0 +1,29 @@
package main
import (
"log"
"os"
// Blank-import the function package so the init() runs
"github.com/GoogleCloudPlatform/functions-framework-go/funcframework"
_ "github.com/ray1729/puzzle-solver/cloudfn"
)
func main() {
// Use PORT environment variable, or default to 8080.
port := "8080"
if envPort := os.Getenv("PORT"); envPort != "" {
port = envPort
}
// By default, listen on all interfaces. If testing locally, run with
// LOCAL_ONLY=true to avoid triggering firewall warnings and
// exposing the server outside of your own machine.
hostname := ""
if localOnly := os.Getenv("LOCAL_ONLY"); localOnly == "true" {
hostname = "127.0.0.1"
}
if err := funcframework.StartHostPort(hostname, port); err != nil {
log.Fatalf("funcframework.StartHostPort: %v\n", err)
}
}

25
fix-wordlist/main.go Normal file
View file

@ -0,0 +1,25 @@
package main
import (
"bufio"
"fmt"
"log"
"os"
"strings"
"golang.org/x/text/encoding/charmap"
)
func main() {
dec := charmap.ISO8859_1.NewDecoder()
sc := bufio.NewScanner(dec.Reader(os.Stdin))
for sc.Scan() {
s := strings.TrimSpace(sc.Text())
if len(s) > 0 {
fmt.Println(s)
}
}
if err := sc.Err(); err != nil {
log.Fatal(err)
}
}

42
go.mod
View file

@ -2,4 +2,44 @@ module github.com/ray1729/puzzle-solver
go 1.21.1 go 1.21.1
require golang.org/x/text v0.13.0 // indirect require (
cloud.google.com/go/storage v1.33.0
github.com/GoogleCloudPlatform/functions-framework-go v1.8.0
github.com/rs/cors v1.10.1
golang.org/x/text v0.13.0
)
require (
cloud.google.com/go v0.110.4 // indirect
cloud.google.com/go/compute v1.20.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/functions v1.15.1 // indirect
cloud.google.com/go/iam v1.1.0 // indirect
github.com/cloudevents/sdk-go/v2 v2.14.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/s2a-go v0.1.4 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/json-iterator/go v1.1.10 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.4.0 // indirect
go.uber.org/multierr v1.1.0 // indirect
go.uber.org/zap v1.10.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/oauth2 v0.10.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.132.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/grpc v1.56.2 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

1633
go.sum

File diff suppressed because it is too large Load diff

25
main.go
View file

@ -1,37 +1,40 @@
package main package main
import ( import (
"bufio"
"log" "log"
"net/http" "net/http"
"os" "os"
"github.com/ray1729/puzzle-solver/anagram" "github.com/ray1729/puzzle-solver/anagram"
"github.com/ray1729/puzzle-solver/grep" "github.com/ray1729/puzzle-solver/match"
"github.com/ray1729/puzzle-solver/server" "github.com/ray1729/puzzle-solver/server"
) )
var grepDB grep.DB var matchDB match.DB
var anagramDB anagram.DB var anagramDB anagram.DB
func init() { func init() {
f, err := os.Open("/usr/share/dict/british-english-huge") f, err := os.Open("wordlist.txt")
if err != nil { if err != nil {
log.Fatalf("Error opening word list: %v", err) log.Fatalf("Error opening word list: %v", err)
} }
defer f.Close() defer f.Close()
grepDB, err = grep.Load(f) matchDB = match.New()
if err != nil { anagramDB = anagram.New()
log.Fatalf("Error loading grep database: %v", err) sc := bufio.NewScanner(f)
for sc.Scan() {
s := sc.Text()
matchDB.Add(s)
anagramDB.Add(s)
} }
f.Seek(0, 0) if err := sc.Err(); err != nil {
anagramDB, err = anagram.Load(f) log.Fatalf("Error loading databases: %v", err)
if err != nil {
log.Fatalf("Error loading anagram database: %v", err)
} }
} }
func main() { func main() {
s := server.New("./assets", grepDB, anagramDB) s := server.New("./assets", matchDB, anagramDB)
address := ":8000" address := ":8000"
log.Printf("Listening on %s", address) log.Printf("Listening on %s", address)
if err := http.ListenAndServe(address, s); err != nil { if err := http.ListenAndServe(address, s); err != nil {

View file

@ -1,4 +1,4 @@
package grep package match
import ( import (
"bufio" "bufio"
@ -9,6 +9,20 @@ import (
type DB interface { type DB interface {
FindMatches(s string) []string FindMatches(s string) []string
Add(s string)
}
type PrefixTreeImpl struct {
Root *Node
}
func New() PrefixTreeImpl {
return PrefixTreeImpl{Root: &Node{}}
}
func (db PrefixTreeImpl) Add(s string) {
xs := util.LowerCaseAlpha(s)
db.Root.add(xs, s)
} }
type Node struct { type Node struct {
@ -17,7 +31,7 @@ type Node struct {
Results []string Results []string
} }
func (n *Node) Add(xs []byte, s string) { func (n *Node) add(xs []byte, s string) {
if len(xs) == 0 { if len(xs) == 0 {
n.Results = append(n.Results, s) n.Results = append(n.Results, s)
return return
@ -34,16 +48,14 @@ func (n *Node) Add(xs []byte, s string) {
child = &Node{Value: x} child = &Node{Value: x}
n.Children = append(n.Children, child) n.Children = append(n.Children, child)
} }
child.Add(xs[1:], s) child.add(xs[1:], s)
} }
func Load(r io.Reader) (DB, error) { func Load(r io.Reader) (DB, error) {
db := &Node{} db := New()
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
for sc.Scan() { for sc.Scan() {
s := sc.Text() db.Add(sc.Text())
xs := util.LowerCaseAlpha(s)
db.Add(xs, s)
} }
if err := sc.Err(); err != nil { if err := sc.Err(); err != nil {
return nil, err return nil, err
@ -51,8 +63,8 @@ func Load(r io.Reader) (DB, error) {
return db, nil return db, nil
} }
func (n *Node) FindMatches(s string) []string { func (db PrefixTreeImpl) FindMatches(s string) []string {
return n.find(util.LowerCaseAlphaOrDot(s)) return db.Root.find(util.LowerCaseAlphaOrDot(s))
} }
func (n *Node) find(xs []byte) []string { func (n *Node) find(xs []byte) []string {

View file

@ -8,13 +8,16 @@ import (
"sort" "sort"
"github.com/ray1729/puzzle-solver/anagram" "github.com/ray1729/puzzle-solver/anagram"
"github.com/ray1729/puzzle-solver/grep" "github.com/ray1729/puzzle-solver/match"
) )
func New(assetsPath string, grepDB grep.DB, anagramDB anagram.DB) http.Handler { func New(assetsPath string, matchDB match.DB, anagramDB anagram.DB) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir(assetsPath)))) mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir(assetsPath))))
mux.HandleFunc("/", handler(grepDB, anagramDB)) mux.HandleFunc("/search", searchHandler(matchDB, anagramDB))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, home, nil)
})
return withRequestLogger(mux) return withRequestLogger(mux)
} }
@ -25,7 +28,7 @@ func withRequestLogger(h http.Handler) http.Handler {
}) })
} }
func handler(grepDB grep.DB, anagramDB anagram.DB) func(w http.ResponseWriter, r *http.Request) { func searchHandler(matchDB match.DB, anagramDB anagram.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
log.Printf("error parsing form: %v", err) log.Printf("error parsing form: %v", err)
@ -34,13 +37,13 @@ func handler(grepDB grep.DB, anagramDB anagram.DB) func(w http.ResponseWriter, r
} }
switch r.Form.Get("mode") { switch r.Form.Get("mode") {
case "match": case "match":
params := matchResults(grepDB, r.Form.Get("pattern")) params := matchResults(matchDB, r.Form.Get("pattern"))
renderTemplate(w, results, params) renderTemplate(w, results, params)
case "anagrams": case "anagrams":
params := anagramResults(anagramDB, r.Form.Get("pattern")) params := anagramResults(anagramDB, r.Form.Get("pattern"))
renderTemplate(w, results, params) renderTemplate(w, results, params)
default: default:
renderTemplate(w, home, nil) renderTemplate(w, results, ResultParams{})
} }
} }
} }
@ -57,7 +60,7 @@ func anagramResults(db anagram.DB, pattern string) ResultParams {
return params return params
} }
func matchResults(db grep.DB, pattern string) ResultParams { func matchResults(db match.DB, pattern string) ResultParams {
var params ResultParams var params ResultParams
params.Results = db.FindMatches(pattern) params.Results = db.FindMatches(pattern)
if len(params.Results) > 0 { if len(params.Results) > 0 {

View file

@ -19,11 +19,12 @@ var home = template.Must(template.New("home").Parse(`
</header> </header>
<main> <main>
<form action="/" method="get" hx-boost="true" hx-target="#results" hx-replace="innerHTML" hx-on::after-request="this.reset()"> <form action="/search" method="post" hx-boost="true" hx-target="#results">
<div class="center"> <div class="center">
<input type="text" name="pattern" required></input> <input type="text" name="pattern" required></input>
<button name="mode" value="match">Match</button> <button name="mode" value="match">Match</button>
<button name="mode" value="anagrams">Anagrams</button> <button name="mode" value="anagrams">Anagrams</button>
<button type="reset">Clear</button>
</div> </div>
</form> </form>
<div id="results"> <div id="results">

240732
wordlist.txt Normal file

File diff suppressed because it is too large Load diff