Add twirp RPC backend API for lure-web
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Elara 2022-12-17 21:01:50 -08:00
parent 19ced9795c
commit 6cd0802f64
10 changed files with 3601 additions and 1 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/lure
/lure-api
/dist/
/internal/config/version.txt

157
cmd/lure-api/api.go Normal file
View File

@ -0,0 +1,157 @@
package main
import (
"context"
"fmt"
"strconv"
"time"
"github.com/genjidb/genji"
"github.com/genjidb/genji/document"
"github.com/genjidb/genji/types"
"go.arsenm.dev/lure/cmd/lure-api/internal/api"
"go.arsenm.dev/lure/internal/db"
)
type lureWebAPI struct {
db *genji.DB
}
func (l lureWebAPI) CreateComment(ctx context.Context, req *api.CreateCommentRequest) (*api.CreateCommentResponse, error) {
count, err := db.CountComments(l.db)
if err != nil {
return nil, err
}
err = db.InsertComment(l.db, db.Comment{
CommentID: count,
PackageName: req.PkgName,
PackageRepo: req.Repository,
TimeCreated: time.Now().Unix(),
Contents: req.Contents,
})
if err != nil {
return nil, err
}
return &api.CreateCommentResponse{CommentId: count}, nil
}
func (l lureWebAPI) EditComment(ctx context.Context, req *api.EditCommentRequest) (*api.EmptyResponse, error) {
doc, err := db.GetComment(l.db, "comment_id = ?", req.CommentId)
if err != nil {
return nil, err
}
var comment db.Comment
err = document.ScanDocument(doc, &comment)
if err != nil {
return nil, err
}
comment.Contents = req.NewContents
err = db.InsertComment(l.db, comment)
return &api.EmptyResponse{}, err
}
func (l lureWebAPI) GetComments(ctx context.Context, req *api.GetCommentsRequest) (*api.GetCommentsResponse, error) {
doc, err := db.GetComments(
l.db,
"package_repo = ? AND package_name = ? AND time_created >= ? LIMIT ?",
req.PkgName,
req.Repository,
req.CreatedSince,
req.Limit,
)
if err != nil {
return nil, err
}
out := &api.GetCommentsResponse{}
err = doc.Iterate(func(d types.Document) error {
comment := &api.Comment{}
err = document.ScanDocument(d, comment)
if err != nil {
return err
}
out.Comments = append(out.Comments, comment)
return nil
})
return out, err
}
func (l lureWebAPI) Search(ctx context.Context, req *api.SearchRequest) (*api.SearchResponse, error) {
query := "(name LIKE ? OR description LIKE ? OR ? IN provides)"
args := []any{"%" + req.Query + "%", "%" + req.Query + "%", req.Query}
if req.FilterValue != nil && req.FilterType != api.FILTER_TYPE_NO_FILTER {
switch req.FilterType {
case api.FILTER_TYPE_IN_REPOSITORY:
query += " AND repository = ?"
case api.FILTER_TYPE_SUPPORTS_ARCH:
query += " AND ? IN architectures"
}
args = append(args, *req.FilterValue)
}
if req.SortBy != api.SORT_BY_UNSORTED {
switch req.SortBy {
case api.SORT_BY_NAME:
query += " ORDER BY name"
case api.SORT_BY_REPOSITORY:
query += " ORDER BY repository"
case api.SORT_BY_VERSION:
query += " ORDER BY version"
}
}
if req.Limit != 0 {
query += " LIMIT " + strconv.FormatInt(req.Limit, 10)
}
doc, err := db.GetPkgs(l.db, query, args...)
if err != nil {
return nil, err
}
fmt.Println(query, args)
out := &api.SearchResponse{}
err = doc.Iterate(func(d types.Document) error {
pkg := &db.Package{}
err = document.ScanDocument(d, pkg)
if err != nil {
return err
}
out.Packages = append(out.Packages, &api.Package{
Name: pkg.Name,
Repository: pkg.Repository,
Version: pkg.Version,
Release: int64(pkg.Release),
Epoch: ptr(int64(pkg.Epoch)),
Description: &pkg.Description,
Homepage: &pkg.Homepage,
Maintainer: &pkg.Maintainer,
Architectures: pkg.Architectures,
Licenses: pkg.Licenses,
Provides: pkg.Provides,
Conflicts: pkg.Conflicts,
Replaces: pkg.Replaces,
Depends: dbMapToAPI(pkg.Depends),
BuildDepends: dbMapToAPI(pkg.BuildDepends),
})
return nil
})
return out, err
}
func ptr[T any](v T) *T {
return &v
}
func dbMapToAPI(m map[string][]string) map[string]*api.StringList {
out := make(map[string]*api.StringList, len(m))
for override, list := range m {
out[override] = &api.StringList{Entries: list}
}
return out
}

23
cmd/lure-api/db.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"github.com/genjidb/genji"
"go.arsenm.dev/logger/log"
"go.arsenm.dev/lure/internal/config"
"go.arsenm.dev/lure/internal/db"
)
var gdb *genji.DB
func init() {
var err error
gdb, err = genji.Open(config.DBPath)
if err != nil {
log.Fatal("Error opening database").Err(err).Send()
}
err = db.Init(gdb)
if err != nil {
log.Fatal("Error initializing database").Err(err).Send()
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

109
cmd/lure-api/lure.proto Normal file
View File

@ -0,0 +1,109 @@
syntax = "proto3";
package lure;
option go_package = "/internal/api";
// EmptyResponse is an empty API response
message EmptyResponse {}
message Comment {
int64 comment_id = 1;
int64 time_created = 2;
string contents = 3;
}
// CreateCommentRequest is a request to create a comment
message CreateCommentRequest {
string repository = 1;
string pkg_name = 2;
string contents = 3;
}
// CreateCommentResponse is a response to CreateCommentRequest
message CreateCommentResponse {
int64 comment_id = 1;
}
// EditCommentRequest is a request to edit a comment
message EditCommentRequest {
int64 comment_id = 1;
string new_contents = 2;
}
// EditCommentRequest is a request to get comments on a package
message GetCommentsRequest {
string repository = 1;
string pkg_name = 2;
int64 created_since = 3;
int64 limit = 4;
}
// EditCommentRequest is a response to GetCommentsRequest
message GetCommentsResponse {
repeated Comment comments = 1;
}
// SORT_BY represents possible things to sort packages by
enum SORT_BY {
UNSORTED = 0;
NAME = 1;
REPOSITORY = 2;
VERSION = 3;
}
// FILTER_TYPE represents possible filters for packages
enum FILTER_TYPE {
NO_FILTER = 0;
IN_REPOSITORY = 1;
SUPPORTS_ARCH = 2;
}
// SearchRequest is a request to search for packages
message SearchRequest {
string query = 1;
int64 limit = 2;
SORT_BY sort_by = 3;
FILTER_TYPE filter_type = 4;
optional string filter_value = 5;
}
// StringList contains a list of strings
message StringList {
repeated string entries = 1;
}
// Package represents a LURE package
message Package {
string name = 1;
string repository = 2;
string version = 3;
int64 release = 4;
optional int64 epoch = 5;
optional string description = 6;
optional string homepage = 7;
optional string maintainer = 8;
repeated string architectures = 9;
repeated string licenses = 10;
repeated string provides = 11;
repeated string conflicts = 12;
repeated string replaces = 13;
map<string, StringList> depends = 14;
map<string, StringList> build_depends = 15;
}
// SearchResponse contains returned packages
message SearchResponse {
repeated Package packages = 1;
}
// Web is the LURE Web service
service API {
// CreateComment creates a new comment on the given package
rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse);
// EditComment edits an existing comment
rpc EditComment(EditCommentRequest) returns (EmptyResponse);
// GetComments returns the comments on a particular package
rpc GetComments(GetCommentsRequest) returns (GetCommentsResponse);
// Search searches through LURE packages in the database
rpc Search(SearchRequest) returns (SearchResponse);
}

53
cmd/lure-api/main.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"flag"
"net"
"net/http"
"os"
"github.com/twitchtv/twirp"
"go.arsenm.dev/logger"
"go.arsenm.dev/logger/log"
"go.arsenm.dev/lure/cmd/lure-api/internal/api"
)
//go:generate protoc --twirp_out=. lure.proto
//go:generate protoc --go_out=. lure.proto
func init() {
log.Logger = logger.NewPretty(os.Stderr)
}
func main() {
addr := flag.String("a", ":8080", "Listen address for API server")
logFile := flag.String("l", "", "Output file for JSON log")
flag.Parse()
if *logFile != "" {
fl, err := os.Create(*logFile)
if err != nil {
log.Fatal("Error creating log file").Err(err).Send()
}
defer fl.Close()
log.Logger = logger.NewMulti(log.Logger, logger.NewJSON(fl))
}
srv := api.NewAPIServer(
lureWebAPI{db: gdb},
twirp.WithServerPathPrefix(""),
)
ln, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatal("Error starting listener").Err(err).Send()
}
log.Info("Starting HTTP API server").Str("addr", ln.Addr().String()).Send()
err = http.Serve(ln, srv)
if err != nil {
log.Fatal("Error while running server").Err(err).Send()
}
}

3
go.mod
View File

@ -18,10 +18,12 @@ require (
github.com/mitchellh/mapstructure v1.5.0
github.com/muesli/reflow v0.3.0
github.com/pelletier/go-toml/v2 v2.0.5
github.com/twitchtv/twirp v8.1.3+incompatible
github.com/urfave/cli/v2 v2.16.3
go.arsenm.dev/logger v0.0.0-20221007032343-cbffce4f4334
golang.org/x/exp v0.0.0-20220916125017-b168a2c6b86b
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.5.1
)
@ -108,6 +110,5 @@ require (
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

2
go.sum
View File

@ -550,6 +550,8 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=

View File

@ -2,6 +2,7 @@ package db
import (
"github.com/genjidb/genji"
"github.com/genjidb/genji/types"
)
// Package is a LURE package's database representation
@ -23,6 +24,15 @@ type Package struct {
Repository string
}
// Package is a LURE Web comment's database representation
type Comment struct {
CommentID int64
PackageName string
PackageRepo string
TimeCreated int64
Contents string
}
// Init initializes the database
func Init(db *genji.DB) error {
return db.Exec(`
@ -44,6 +54,16 @@ func Init(db *genji.DB) error {
builddepends (...),
UNIQUE(name, repository)
);
CREATE TABLE IF NOT EXISTS comments (
comment_id INT PRIMARY KEY,
package_name TEXT NOT NULL,
package_repo TEXT NOT NULL,
time_created INT NOT NULL,
contents TEXT NOT NULL,
UNIQUE(comment_id),
UNIQUE(package_name, package_repo)
);
`)
}
@ -65,3 +85,42 @@ func GetPkgs(db *genji.DB, where string, args ...any) (*genji.Result, error) {
func DeletePkgs(db *genji.DB, where string, args ...any) error {
return db.Exec("DELETE FROM pkgs WHERE "+where, args...)
}
func InsertComment(db *genji.DB, c Comment) error {
return db.Exec("INSERT INTO comments VALUES ? ON CONFLICT DO REPLACE;", c)
}
func CountComments(db *genji.DB) (int64, error) {
doc, err := db.QueryDocument("SELECT count(*) FROM comments;")
if err != nil {
return 0, err
}
val, err := doc.GetByField("COUNT(*)")
if err != nil {
return 0, err
}
return val.V().(int64), nil
}
// GetComments returns a result containing comments that match the where conditions
func GetComments(db *genji.DB, where string, args ...any) (*genji.Result, error) {
stream, err := db.Query("SELECT * FROM comments WHERE "+where, args...)
if err != nil {
return nil, err
}
return stream, nil
}
// GetComment returns a comment that matches the where conditions
func GetComment(db *genji.DB, where string, args ...any) (types.Document, error) {
doc, err := db.QueryDocument("SELECT * FROM comments WHERE "+where+" LIMIT 1", args...)
if err != nil {
return nil, err
}
return doc, nil
}
// DeleteComments deletes all comments matching the where conditions
func DeleteComments(db *genji.DB, where string, args ...any) error {
return db.Exec("DELETE FROM comments WHERE "+where, args...)
}