浏览代码

rewrite to use a local db instead of csv

Aneurin Barker Snook 11 月之前
父节点
当前提交
2642e00208
共有 10 个文件被更改,包括 387 次插入86 次删除
  1. 1 0
      .gitignore
  2. 13 1
      go.mod
  3. 34 0
      go.sum
  4. 98 0
      internal/api/api.go
  5. 52 0
      internal/cli/add.go
  6. 18 0
      internal/cli/cli.go
  7. 59 0
      internal/cli/get.go
  8. 51 0
      internal/cli/rm.go
  9. 42 0
      internal/cli/start.go
  10. 19 85
      main.go

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 .env
+.shorty

+ 13 - 1
go.mod

@@ -3,9 +3,21 @@ module github.com/recipeer/short-url-service
 go 1.21.3
 
 require (
+	github.com/alecthomas/kong v0.9.0
+	github.com/annybs/ezdb v0.0.0-20240621195216-a3d8644ce481
+	github.com/annybs/go/validate v0.0.0-20240621205444-13a966214726
+	github.com/rs/zerolog v1.33.0
+	github.com/spf13/viper v1.17.0
+)
+
+require (
+	github.com/annybs/go/rest v0.0.0-20240622161209-822dcc4b52be // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
+	github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.19 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.1.0 // indirect
 	github.com/sagikazarmark/locafero v0.3.0 // indirect
@@ -14,8 +26,8 @@ require (
 	github.com/spf13/afero v1.10.0 // indirect
 	github.com/spf13/cast v1.5.1 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/spf13/viper v1.17.0 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
+	github.com/syndtr/goleveldb v1.0.0 // indirect
 	go.uber.org/atomic v1.9.0 // indirect
 	go.uber.org/multierr v1.9.0 // indirect
 	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect

+ 34 - 0
go.sum

@@ -38,6 +38,14 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
+github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
+github.com/annybs/ezdb v0.0.0-20240621195216-a3d8644ce481 h1:cjRW2GKH/q9EfTt2zzYXNPbBfnGgWYjYKW6mCt3qxtM=
+github.com/annybs/ezdb v0.0.0-20240621195216-a3d8644ce481/go.mod h1:eWik4kAFQYV94eUfDYnDHYV5bz4+cmqmuHfjBs6qM3s=
+github.com/annybs/go/rest v0.0.0-20240622161209-822dcc4b52be h1:UnMe8ubB0C+LBi8EWl7JUFLj+KiyWkL3VGG6K2PS8WI=
+github.com/annybs/go/rest v0.0.0-20240622161209-822dcc4b52be/go.mod h1:Rlyy9gpI2n0HM/NBFSMBQ5f8hdH36pSCIpvCNWndMMU=
+github.com/annybs/go/validate v0.0.0-20240621205444-13a966214726 h1:sbBrFZbXtyFP9TNTTmbOCTu7ygCObj3aO6BZI/ZK3rY=
+github.com/annybs/go/validate v0.0.0-20240621205444-13a966214726/go.mod h1:tKjs2z5yskCVtKikCpGqORUCJiQdX7cSlOXAQjdNBpI=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -46,6 +54,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -54,11 +63,13 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
 github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -84,6 +95,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -117,6 +130,7 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -128,8 +142,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
 github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -137,6 +159,9 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
+github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
 github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
 github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
 github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -163,6 +188,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
+github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -221,6 +248,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -271,6 +299,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -305,7 +334,9 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -464,8 +495,11 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 98 - 0
internal/api/api.go

@@ -0,0 +1,98 @@
+package api
+
+import (
+	"io"
+	"net/http"
+
+	"github.com/annybs/ezdb"
+	"github.com/annybs/go/rest"
+	"github.com/annybs/go/validate"
+	"github.com/rs/zerolog"
+)
+
+type API struct {
+	DB  ezdb.Collection[[]byte]
+	Log zerolog.Logger
+
+	Token string
+}
+
+func (api *API) Delete(w http.ResponseWriter, req *http.Request) {
+	if !rest.IsAuthenticated(req, api.Token) {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte{})
+		return
+	}
+
+	path := req.URL.Path
+
+	if exist, _ := api.DB.Has(path); !exist {
+		w.Write([]byte{})
+		return
+	}
+
+	if err := api.DB.Delete(path); err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(err.Error()))
+	} else {
+		w.Write([]byte{})
+	}
+}
+
+func (api *API) Get(w http.ResponseWriter, req *http.Request) {
+	path := req.URL.Path
+
+	dest, _ := api.DB.Get(path)
+	if len(dest) > 0 {
+		w.Header().Set("Location", string(dest))
+		w.WriteHeader(http.StatusPermanentRedirect)
+	} else {
+		w.WriteHeader(http.StatusNotFound)
+	}
+	w.Write([]byte{})
+}
+
+func (api *API) Put(w http.ResponseWriter, req *http.Request) {
+	if !rest.IsAuthenticated(req, api.Token) {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte{})
+		return
+	}
+
+	path := req.URL.Path
+
+	dest, err := io.ReadAll(req.Body)
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte(err.Error()))
+		return
+	}
+	if err := validate.URL(string(dest)); err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte(err.Error()))
+		return
+	}
+
+	if err := api.DB.Put(path, dest); err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(err.Error()))
+	} else {
+		w.Write([]byte{})
+	}
+}
+
+func (api *API) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	api.Log.Info().Msgf("%s %s", req.Method, req.URL.Path)
+
+	switch req.Method {
+	case http.MethodGet:
+		api.Get(w, req)
+	case http.MethodPut:
+		api.Put(w, req)
+	case http.MethodDelete:
+		api.Delete(w, req)
+	default:
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte("unsupported method"))
+	}
+}

+ 52 - 0
internal/cli/add.go

@@ -0,0 +1,52 @@
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+type AddCmd struct {
+	Token string `short:"t" help:"Bearer token (SHORTY_TOKEN)" default:"${token}"`
+	URL   string `short:"u" help:"Shorty URL (SHORTY_URL)" default:"${url}"`
+
+	Path        string `arg:"" help:"Redirect path"`
+	Destination string `arg:"" help:"Destination URL"`
+}
+
+func (c *AddCmd) Run(ctx *Context) error {
+	url := strings.Join([]string{c.URL, c.Path}, "")
+	req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(c.Destination))
+	if err != nil {
+		return err
+	}
+	if c.Token != "" {
+		req.Header.Add("authorization", fmt.Sprintf("bearer %s", c.Token))
+	}
+
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	if res.StatusCode != http.StatusOK {
+		body, err := io.ReadAll(res.Body)
+		if err != nil {
+			return err
+		}
+		if len(body) > 0 {
+			return errors.New(string(body))
+		}
+		return errors.New(res.Status)
+	}
+
+	return nil
+}
+
+func (c *AddCmd) Validate() error {
+	if len(c.Path) < 1 || c.Path[0] != '/' {
+		return errors.New("invalid <path>")
+	}
+	return nil
+}

+ 18 - 0
internal/cli/cli.go

@@ -0,0 +1,18 @@
+package cli
+
+import (
+	"github.com/rs/zerolog"
+	"github.com/spf13/viper"
+)
+
+type Context struct {
+	Config *viper.Viper
+	Log    zerolog.Logger
+}
+
+type CLI struct {
+	Add   AddCmd   `cmd:"" help:"Add a redirect"`
+	Get   GetCmd   `cmd:"" help:"Get a redirect"`
+	Rm    RmCmd    `cmd:"" help:"Delete a redirect"`
+	Start StartCmd `cmd:"" help:"Start Shorty"`
+}

+ 59 - 0
internal/cli/get.go

@@ -0,0 +1,59 @@
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+type GetCmd struct {
+	URL string `short:"u" help:"Shorty URL (SHORTY_URL)" default:"${url}"`
+
+	Path string `arg:"" help:"Redirect path"`
+}
+
+func (c *GetCmd) Run(ctx *Context) error {
+	url := strings.Join([]string{c.URL, c.Path}, "")
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return err
+	}
+
+	// Create a specialized client that ignores redirection
+	client := &http.Client{
+		CheckRedirect: func(*http.Request, []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+
+	res, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	if res.StatusCode == http.StatusNotFound {
+		return nil
+	} else if res.StatusCode != http.StatusPermanentRedirect {
+		body, err := io.ReadAll(res.Body)
+		if err != nil {
+			return err
+		}
+		if len(body) > 0 {
+			return errors.New(string(body))
+		}
+		return errors.New(res.Status)
+	}
+
+	dest := res.Header.Get("location")
+	fmt.Println(dest)
+
+	return nil
+}
+
+func (c *GetCmd) Validate() error {
+	if len(c.Path) < 1 || c.Path[0] != '/' {
+		return errors.New("invalid <path>")
+	}
+	return nil
+}

+ 51 - 0
internal/cli/rm.go

@@ -0,0 +1,51 @@
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+type RmCmd struct {
+	Token string `short:"t" help:"Bearer token (SHORTY_TOKEN)" default:"${token}"`
+	URL   string `short:"u" help:"Shorty URL (SHORTY_URL)" default:"${url}"`
+
+	Path string `arg:"" help:"Redirect path"`
+}
+
+func (c *RmCmd) Run(ctx *Context) error {
+	url := strings.Join([]string{c.URL, c.Path}, "")
+	req, err := http.NewRequest(http.MethodDelete, url, nil)
+	if err != nil {
+		return err
+	}
+	if c.Token != "" {
+		req.Header.Add("authorization", fmt.Sprintf("bearer %s", c.Token))
+	}
+
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	if res.StatusCode != http.StatusOK {
+		body, err := io.ReadAll(res.Body)
+		if err != nil {
+			return err
+		}
+		if len(body) > 0 {
+			return errors.New(string(body))
+		}
+		return errors.New(res.Status)
+	}
+
+	return nil
+}
+
+func (c *RmCmd) Validate() error {
+	if len(c.Path) < 1 || c.Path[0] != '/' {
+		return errors.New("invalid <path>")
+	}
+	return nil
+}

+ 42 - 0
internal/cli/start.go

@@ -0,0 +1,42 @@
+package cli
+
+import (
+	"net"
+	"net/http"
+
+	"github.com/annybs/ezdb"
+	"github.com/recipeer/short-url-service/internal/api"
+)
+
+type StartCmd struct {
+	DatabasePath string `short:"d" help:"Database path (SHORTY_DATABASE_PATH)" default:"${database_path}"`
+	Host         string `short:"h" help:"HTTP bind host (SHORTY_HOST)" default:"${host}"`
+	Port         string `short:"p" help:"HTTP port (SHORTY_PORT)" default:"${port}"`
+	Token        string `short:"t" help:"Bearer token (SHORTY_TOKEN)" default:"${token}"`
+}
+
+func (c *StartCmd) Run(ctx *Context) error {
+	errc := make(chan error)
+
+	db := ezdb.Memory(
+		ezdb.LevelDB(c.DatabasePath, ezdb.Bytes(), nil),
+	)
+	if err := db.Open(); err != nil {
+		return err
+	}
+
+	a := &api.API{
+		DB:  db,
+		Log: ctx.Log,
+
+		Token: c.Token,
+	}
+
+	addr := net.JoinHostPort(c.Host, c.Port)
+	go func() {
+		errc <- http.ListenAndServe(addr, a)
+	}()
+
+	ctx.Log.Info().Msgf("Listening at %s", addr)
+	return <-errc
+}

+ 19 - 85
main.go

@@ -1,99 +1,33 @@
 package main
 
 import (
-	"encoding/csv"
-	"errors"
-	"fmt"
-	"net"
-	"net/http"
-	"os"
-	"os/signal"
-	"strings"
-	"syscall"
-
+	"github.com/alecthomas/kong"
+	"github.com/recipeer/short-url-service/internal/cli"
+	"github.com/rs/zerolog"
 	"github.com/spf13/viper"
 )
 
-// ListenHTTP starts an HTTP server that redirects any request for a recognised path to the corresponding URL.
-// The server address and port can be configured with HOST and PORT environment variables, respectively.
-// The default port is 3000.
-func ListenHTTP(config *viper.Viper, redirects map[string]string) <-chan error {
-	errc := make(chan error)
-
-	addr := net.JoinHostPort(config.GetString("host"), config.GetString("port"))
-	go func() {
-		http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
-			path := req.URL.Path
-			dest := redirects[path]
-			code := 404
-			if dest != "" {
-				code = 308
-				w.Header().Set("Location", dest)
-			}
-			w.WriteHeader(code)
-			w.Write([]byte{})
-			fmt.Println(path, code, dest)
-		})
-		errc <- http.ListenAndServe(addr, http.DefaultServeMux)
-	}()
-
-	fmt.Println("Listening at", addr)
-	return errc
-}
-
-// ReadRedirects parses a map of redirects from the CSV environment variable (literally, "CSV").
-// The CSV data must be in the format:
-//
-//	path,url
-//	/some/path,https://some-url.com
-//
-// The first line is always skipped, allowing for CSV headings.
-func ReadRedirects(config *viper.Viper) (map[string]string, error) {
-	data := config.GetString("csv")
-	if data == "" {
-		return nil, errors.New("no CSV data")
-	}
-	reader := csv.NewReader(strings.NewReader(data))
-	rows, err := reader.ReadAll()
-	if err != nil {
-		return nil, err
-	}
-	redirects := map[string]string{}
-	for i, row := range rows {
-		if i == 0 {
-			continue
-		}
-		if len(row) != 2 {
-			return nil, fmt.Errorf("invalid line %d", i)
-		}
-		redirects[row[0]] = row[1]
-	}
-	return redirects, nil
-}
-
 func main() {
+	// Initialise config from environment
 	config := viper.New()
-	config.SetConfigFile(".env")
+	config.SetEnvPrefix("shorty")
+	config.SetDefault("database_path", ".shorty")
 	config.SetDefault("port", "3000")
-	config.ReadInConfig()
+	config.SetDefault("url", "http://localhost:3000")
 	config.AutomaticEnv()
 
-	redirects, err := ReadRedirects(config)
-	if err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-
-	errc := ListenHTTP(config, redirects)
-
-	sigc := make(chan os.Signal, 1)
-	signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
+	// Create logger
+	log := zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger()
 
-	select {
-	case err := <-errc:
-		fmt.Println(err)
-		os.Exit(1)
-	case <-sigc:
-		fmt.Println("Stopped")
+	// Initialise and run CLI
+	vars := kong.Vars{
+		"database_path": config.GetString("database_path"),
+		"host":          config.GetString("host"),
+		"port":          config.GetString("port"),
+		"token":         config.GetString("token"),
+		"url":           config.GetString("url"),
 	}
+	ctx := kong.Parse(&cli.CLI{}, vars)
+	err := ctx.Run(&cli.Context{Config: config, Log: log})
+	ctx.FatalIfErrorf(err)
 }