aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <m.shulhan@gmail.com>2020-03-31 01:11:26 +0700
committerShulhan <m.shulhan@gmail.com>2020-04-01 06:28:03 +0700
commit8a141995aabdee289d2b096bd77a10b52b08a1bf (patch)
tree98af569d4c3e2620809ce591c2733184cd77db8f
parent84fdfdb6ae4175a125fc67a6aed377476d31ee0e (diff)
downloadkamusku-8a141995aabdee289d2b096bd77a10b52b08a1bf.tar.xz
all: implement server and client for dictionary API
Currently the server and client can onyl handle API for looking up definitions of the words through "/api/definisi" URL.
-rw-r--r--.gitignore2
-rw-r--r--Makefile25
-rw-r--r--active_client.go2
-rw-r--r--api_client.go92
-rw-r--r--api_client_test.go103
-rw-r--r--client.go42
-rw-r--r--cmd/www-kbbi/main.go31
-rw-r--r--daftar_kata.go4
-rw-r--r--definisi_kata.go4
-rw-r--r--definisi_response.go10
-rw-r--r--direct_client.go14
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--kamus_cache.go165
-rw-r--r--kamus_cache_test.go36
-rw-r--r--kbbi.go10
-rw-r--r--kbbi_test.go59
-rw-r--r--server.go167
18 files changed, 737 insertions, 35 deletions
diff --git a/.gitignore b/.gitignore
index fb4a074..2d3eb1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
/internal/cmd/mergedic/id_ID.dic
/internal/cmd/mergedic/id_ID.dic.new
/kbbi
+/testdata/kamus.gob
+/testdata/kamus.gob.new
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..192eed6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,25 @@
+## Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+## Use of this source code is governed by a BSD-style
+## license that can be found in the LICENSE file.
+
+.PHONY: all build lint test
+
+LINT_OPTS =
+
+all: build lint test
+
+build:
+ go build ./...
+
+lint:
+ golangci-lint run --enable-all \
+ --disable=dupl \
+ --disable=funlen \
+ --disable=godox \
+ --disable=gomnd \
+ --disable=wsl \
+ --disable=gocognit \
+ ./...
+
+test:
+ go test ./...
diff --git a/active_client.go b/active_client.go
index 061bd1d..0aebd0b 100644
--- a/active_client.go
+++ b/active_client.go
@@ -9,5 +9,5 @@ package kbbi
//
type activeClient interface {
CariDefinisi(words []string) (res DefinisiResponse, err error)
- ListKataDasar() (kataDasar daftarKata, err error)
+ ListKataDasar() (kataDasar DaftarKata, err error)
}
diff --git a/api_client.go b/api_client.go
new file mode 100644
index 0000000..eb1c2f7
--- /dev/null
+++ b/api_client.go
@@ -0,0 +1,92 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package kbbi
+
+import (
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+//
+// apiClient is client that connect to cached server API.
+//
+type apiClient struct {
+ url string
+ conn *http.Client
+}
+
+//
+// newAPIClient create a new client for server API.
+// If url is empty it will use default server API.
+//
+func newAPIClient(url string) (client *apiClient) {
+ if len(url) == 0 {
+ url = defServerAPI
+ }
+
+ client = &apiClient{
+ url: url,
+ conn: &http.Client{
+ Timeout: defTimeout,
+ },
+ }
+
+ return client
+}
+
+//
+// Lookup the definition of words through server API.
+//
+func (client *apiClient) CariDefinisi(words []string) (
+ res DefinisiResponse, err error,
+) {
+ if len(words) == 0 {
+ return nil, nil
+ }
+
+ params := url.Values{}
+ params.Set(paramNameKata, strings.Join(words, ","))
+
+ req, err := http.NewRequest(http.MethodGet, client.url+pathAPIDefinisi, nil)
+ if err != nil {
+ return nil, fmt.Errorf("CariDefinisi: %w", err)
+ }
+
+ req.URL.RawQuery = params.Encode()
+
+ httpRes, err := client.conn.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("CariDefinisi: %w", err)
+ }
+
+ resBody, err := ioutil.ReadAll(httpRes.Body)
+ if err != nil {
+ return nil, fmt.Errorf("CariDefinisi: %w", err)
+ }
+
+ defer httpRes.Body.Close()
+
+ if len(resBody) == 0 {
+ return res, nil
+ }
+
+ err = res.unpack(resBody)
+ if err != nil {
+ return nil, fmt.Errorf("CariDefinisi: %w", err)
+ }
+
+ return res, nil
+}
+
+//
+// ListKataDasar list all of the root words in dictionary.
+//
+func (client *apiClient) ListKataDasar() (res DaftarKata, err error) {
+ //TODO: return cached list.
+ return res, nil
+}
diff --git a/api_client_test.go b/api_client_test.go
new file mode 100644
index 0000000..23e1e81
--- /dev/null
+++ b/api_client_test.go
@@ -0,0 +1,103 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package kbbi
+
+import (
+ "testing"
+
+ "github.com/shuLhan/share/lib/test"
+)
+
+func TestApiClient_CariDefinisi_offline(t *testing.T) {
+ testServer.offline = true
+
+ client := newAPIClient(testServerAPI)
+
+ cases := []struct {
+ desc string
+ words []string
+ exp DefinisiResponse
+ expError string
+ }{{
+ desc: "With empty input",
+ }, {
+ desc: "With words not found",
+ words: []string{"a"},
+ }, {
+ desc: "With valid word in cache",
+ words: []string{"mengeja"},
+ exp: DefinisiResponse{
+ "mengeja": testKataMengeja,
+ },
+ }, {
+ desc: "With duplicate words",
+ words: []string{"mengeja", "mengeja"},
+ exp: DefinisiResponse{
+ "mengeja": testKataMengeja,
+ },
+ }}
+
+ for _, c := range cases {
+ t.Logf(c.desc)
+
+ got, err := client.CariDefinisi(c.words)
+ if err != nil {
+ test.Assert(t, "error", c.expError, err.Error(), true)
+ continue
+ }
+
+ test.Assert(t, "DefinisiResponse", c.exp, got, true)
+ }
+}
+
+func TestApiClient_CariDefinisi_online(t *testing.T) {
+ testServer.offline = false
+
+ client := newAPIClient(testServerAPI)
+
+ cases := []struct {
+ desc string
+ words []string
+ exp DefinisiResponse
+ expError string
+ }{{
+ desc: "With empty input",
+ }, {
+ desc: "With valid word in cache",
+ words: []string{"mengeja"},
+ exp: DefinisiResponse{
+ "mengeja": testKataMengeja,
+ },
+ }, {
+ desc: "With duplicate words",
+ words: []string{"mengeja", "mengeja"},
+ exp: DefinisiResponse{
+ "mengeja": testKataMengeja,
+ },
+ }, {
+ desc: "With one of the word not in cache",
+ words: []string{"mengeja", "eja"},
+ exp: DefinisiResponse{
+ "mengeja": testKataMengeja,
+ "eja": testKataEja,
+ },
+ }}
+
+ for _, c := range cases {
+ t.Logf(c.desc)
+
+ got, err := client.CariDefinisi(c.words)
+ if err != nil {
+ test.Assert(t, "error", c.expError, err.Error(), true)
+ continue
+ }
+
+ for k, v := range got {
+ t.Logf("got: %s = %+v", k, v)
+ }
+
+ test.Assert(t, "DefinisiResponse", c.exp, got, true)
+ }
+}
diff --git a/client.go b/client.go
index fabe600..c23100f 100644
--- a/client.go
+++ b/client.go
@@ -4,13 +4,16 @@
package kbbi
-import "net/http"
+import (
+ "net/http"
+)
//
-// Client for dictionary API and official KBBI servers.
+// Client for dictionary API and official KBBI server.
//
type Client struct {
active activeClient
+ api *apiClient
direct *directClient
}
@@ -21,13 +24,17 @@ type Client struct {
func NewClient(cookies []*http.Cookie) (cl *Client, err error) {
cl = &Client{}
+ cl.direct, err = newDirectClient(cookies)
+ if err != nil {
+ return nil, err
+ }
+
+ cl.api = newAPIClient("")
+
if cookies != nil {
- cl.direct, err = newDirectClient(cookies)
- if err != nil {
- return nil, err
- }
cl.active = cl.direct
- return cl, nil
+ } else {
+ cl.active = cl.api
}
return cl, nil
@@ -43,29 +50,28 @@ func (cl *Client) CariDefinisi(words []string) (
return cl.active.CariDefinisi(words)
}
- // TODO: start with api client first ...
-
- cl.direct, err = newDirectClient(nil)
+ res, err = cl.api.CariDefinisi(words)
if err != nil {
- return nil, err
+ return cl.direct.CariDefinisi(words)
}
- return cl.direct.CariDefinisi(words)
+ return res, nil
}
-func (cl *Client) ListKataDasar() (kataDasar daftarKata, err error) {
+//
+// ListKataDasar list all of the root words in dictionary.
+//
+func (cl *Client) ListKataDasar() (res DaftarKata, err error) {
if cl.active != nil {
return cl.active.ListKataDasar()
}
- // TODO: start with api client first ...
-
- cl.direct, err = newDirectClient(nil)
+ res, err = cl.api.ListKataDasar()
if err != nil {
- return nil, err
+ return cl.direct.ListKataDasar()
}
- return cl.direct.ListKataDasar()
+ return res, nil
}
//
diff --git a/cmd/www-kbbi/main.go b/cmd/www-kbbi/main.go
new file mode 100644
index 0000000..bb4cd86
--- /dev/null
+++ b/cmd/www-kbbi/main.go
@@ -0,0 +1,31 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+ "log"
+ "os"
+ "os/signal"
+
+ "github.com/shuLhan/kbbi"
+)
+
+func main() {
+ server, err := kbbi.NewServer()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ go func() {
+ err = server.Start()
+ if err != nil {
+ log.Println(err)
+ }
+ }()
+
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, os.Interrupt)
+ <-c
+}
diff --git a/daftar_kata.go b/daftar_kata.go
index ba407f9..1560355 100644
--- a/daftar_kata.go
+++ b/daftar_kata.go
@@ -4,12 +4,12 @@
package kbbi
-type daftarKata map[string]struct{}
+type DaftarKata map[string]struct{}
//
// merge other map into current map.
//
-func (dk daftarKata) merge(in daftarKata) daftarKata {
+func (dk DaftarKata) merge(in DaftarKata) DaftarKata {
for k := range in {
dk[k] = struct{}{}
}
diff --git a/definisi_kata.go b/definisi_kata.go
index 26b78f7..ac8ba5d 100644
--- a/definisi_kata.go
+++ b/definisi_kata.go
@@ -17,8 +17,8 @@ import (
//
type DefinisiKata struct {
Isi string `json:"isi"`
- Kelas []string `json:"kelas"`
- Contoh []string `json:"contoh"`
+ Kelas []string `json:"kelas,omitempty"`
+ Contoh []string `json:"contoh,omitempty"`
}
func parseDefinisiKata(li *html.Node) (defKata *DefinisiKata) {
diff --git a/definisi_response.go b/definisi_response.go
index d842310..91808eb 100644
--- a/definisi_response.go
+++ b/definisi_response.go
@@ -4,8 +4,18 @@
package kbbi
+import "encoding/json"
+
//
// DefinisiResponse is a response from "/definisi" API.
// Its contains mapping of words and their definitions.
//
type DefinisiResponse map[string]*Kata
+
+func (res *DefinisiResponse) pack() ([]byte, error) {
+ return json.Marshal(res)
+}
+
+func (res *DefinisiResponse) unpack(v []byte) (err error) {
+ return json.Unmarshal(v, res)
+}
diff --git a/direct_client.go b/direct_client.go
index 3123829..7c944e0 100644
--- a/direct_client.go
+++ b/direct_client.go
@@ -14,7 +14,6 @@ import (
"net/url"
"strconv"
"strings"
- "time"
"github.com/shuLhan/share/lib/debug"
"golang.org/x/net/html"
@@ -23,7 +22,6 @@ import (
const (
maxPageNumber = 501
- defTimeout = 20 * time.Second
)
//
@@ -41,7 +39,7 @@ type directClient struct {
func newDirectClient(cookies []*http.Cookie) (cl *directClient, err error) {
cookieURL, err := url.Parse(baseURL)
if err != nil {
- return nil, fmt.Errorf("New: %w", err)
+ return nil, fmt.Errorf("newDirectClient: %w", err)
}
jarOpt := &cookiejar.Options{
@@ -114,9 +112,9 @@ func (cl *directClient) CariDefinisi(ins []string) (
}
//
-// ListKataDasar get list of kata dasar
+// ListKataDasar list all of the root words in dictionary.
//
-func (cl *directClient) ListKataDasar() (kataDasar daftarKata, err error) {
+func (cl *directClient) ListKataDasar() (kataDasar DaftarKata, err error) {
params := url.Values{
paramNameMasukan: []string{paramValueDasar},
paramNameMasukanLengkap: []string{paramValueDasar},
@@ -124,7 +122,7 @@ func (cl *directClient) ListKataDasar() (kataDasar daftarKata, err error) {
urlPage := baseURL + "/Cari/Jenis?"
- kataDasar = make(daftarKata)
+ kataDasar = make(DaftarKata)
for pageNumber := 1; pageNumber <= maxPageNumber; pageNumber++ {
params.Set(paramNamePage, strconv.Itoa(pageNumber))
@@ -224,14 +222,14 @@ func (cl *directClient) setCookies(cookies []*http.Cookie) {
}
func (cl *directClient) parseHTMLKataDasar(htmlBody []byte) (
- kataDasar daftarKata, err error,
+ kataDasar DaftarKata, err error,
) {
node, err := html.Parse(bytes.NewReader(htmlBody))
if err != nil {
return nil, err
}
- kataDasar = make(daftarKata)
+ kataDasar = make(DaftarKata)
var prev *html.Node
diff --git a/go.mod b/go.mod
index c33e32d..b8f3599 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/shuLhan/kbbi
go 1.13
require (
- github.com/shuLhan/share v0.13.1-0.20200328211544-f4912dbb53e0
+ github.com/shuLhan/share v0.13.1-0.20200330125604-7ac43c699173
golang.org/x/net v0.0.0-20200320220750-118fecf932d8
)
diff --git a/go.sum b/go.sum
index a038423..d479867 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/shuLhan/share v0.13.1-0.20200328211544-f4912dbb53e0 h1:/yJph5LPHE21i+A5B8PBthxbAjRx50V+28af/I64vN4=
-github.com/shuLhan/share v0.13.1-0.20200328211544-f4912dbb53e0/go.mod h1:uG1C5VfU81bI4iQ48VbWRm5c7mkvpr4huuUO54PKK1o=
+github.com/shuLhan/share v0.13.1-0.20200330125604-7ac43c699173 h1:lhiuIUynM8i0EdntUiy0gnyBcvRmkyrXkdQBPDf0iJw=
+github.com/shuLhan/share v0.13.1-0.20200330125604-7ac43c699173/go.mod h1:uG1C5VfU81bI4iQ48VbWRm5c7mkvpr4huuUO54PKK1o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
diff --git a/kamus_cache.go b/kamus_cache.go
new file mode 100644
index 0000000..ffcf4db
--- /dev/null
+++ b/kamus_cache.go
@@ -0,0 +1,165 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package kbbi
+
+import (
+ "bytes"
+ "encoding/gob"
+ "errors"
+ "io/ioutil"
+ "log"
+ "os"
+ "sync"
+
+ libio "github.com/shuLhan/share/lib/io"
+)
+
+const (
+ defStorageName = "kamus.gob"
+)
+
+//
+// kamusCache contains cache of words and its definitions.
+//
+type kamusCache struct {
+ sync.Mutex
+ cache map[string]*Kata
+ lastSize int
+
+ storagePath string
+}
+
+//
+// newKamusCache create and initialize the cache for dictionary.
+//
+func newKamusCache(storagePath string) (kamusc *kamusCache, err error) {
+ if len(storagePath) == 0 {
+ storagePath = defStorageName
+ }
+
+ kamusc = &kamusCache{
+ cache: make(map[string]*Kata),
+ storagePath: storagePath,
+ }
+
+ err = kamusc.load()
+ if err != nil {
+ return nil, err
+ }
+
+ return kamusc, nil
+}
+
+//
+// get the definition of word from cache or nil if not exist.
+//
+func (kamus *kamusCache) get(word string) (kata *Kata) {
+ kamus.Lock()
+ kata = kamus.cache[word]
+ kamus.Unlock()
+ return kata
+}
+
+//
+// isChanging will return true if the last cache size is not equal with
+// current size.
+//
+func (kamus *kamusCache) isChanging() bool {
+ kamus.Lock()
+ defer kamus.Unlock()
+ return kamus.lastSize != len(kamus.cache)
+}
+
+//
+// load the cached dictionary from storage.
+//
+func (kamus *kamusCache) load() (err error) {
+ kamus.Lock()
+ defer kamus.Unlock()
+
+ v, err := ioutil.ReadFile(kamus.storagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+
+ r := bytes.NewReader(v)
+
+ dec := gob.NewDecoder(r)
+ err = dec.Decode(&kamus.cache)
+ if err != nil {
+ return err
+ }
+
+ kamus.lastSize = len(kamus.cache)
+
+ return nil
+}
+
+//
+// set save the definition of word into cache.
+//
+func (kamus *kamusCache) set(word string, kata *Kata) {
+ if len(word) == 0 || kata == nil {
+ return
+ }
+
+ kamus.Lock()
+ kamus.cache[word] = kata
+ kamus.Unlock()
+}
+
+//
+// store the cache to file only if the storage path is set.
+//
+func (kamus *kamusCache) store() (err error) {
+ if len(kamus.storagePath) == 0 {
+ return nil
+ }
+
+ kamus.Lock()
+ defer kamus.Unlock()
+
+ if len(kamus.cache) == 0 {
+ return nil
+ }
+
+ newStorage := kamus.storagePath + ".new"
+
+ f, err := os.Create(newStorage)
+ if err != nil {
+ errc := f.Close()
+ if errc != nil {
+ log.Println("kamusCache: store: ", err)
+ }
+ return err
+ }
+
+ enc := gob.NewEncoder(f)
+ err = enc.Encode(&kamus.cache)
+ if err != nil {
+ errc := f.Close()
+ if errc != nil {
+ log.Println("kamusCache: store: ", err)
+ }
+ return err
+ }
+
+ errc := f.Close()
+ if errc != nil {
+ log.Println("kamusCache: store: ", err)
+ }
+
+ err = libio.Copy(kamus.storagePath, newStorage)
+ if err != nil {
+ return err
+ }
+
+ kamus.lastSize = len(kamus.cache)
+
+ return nil
+}
diff --git a/kamus_cache_test.go b/kamus_cache_test.go
new file mode 100644
index 0000000..fbeda02
--- /dev/null
+++ b/kamus_cache_test.go
@@ -0,0 +1,36 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package kbbi
+
+import (
+ "testing"
+
+ "github.com/shuLhan/share/lib/test"
+)
+
+const (
+ testStoragePath = "testdata/kamus.gob"
+)
+
+func TestKamusCache_store_load(t *testing.T) {
+ exp, err := newKamusCache(testStoragePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ exp.set("mengeja", testKataMengeja)
+
+ err = exp.store()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := newKamusCache(testStoragePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ test.Assert(t, "store and load", exp, got, true)
+}
diff --git a/kbbi.go b/kbbi.go
index b5b46eb..ffecd30 100644
--- a/kbbi.go
+++ b/kbbi.go
@@ -8,12 +8,17 @@
//
package kbbi
+import "time"
+
const (
hostname = "kbbi.kemdikbud.go.id"
baseURL = "https://" + hostname
loginURL = baseURL + "/Account/Login"
entriPath = "/entri/"
+ defServerAPI = "https://kilabit.info/project/kbbi"
+ pathAPIDefinisi = "/api/definisi"
+
attrNameClass = "class"
attrNameHref = "href"
attrNameName = "name"
@@ -35,13 +40,16 @@ const (
tagNameUnorderedList = "ul"
paramNameIngatSaya = "IngatSaya"
+ paramNameKata = "kata"
paramNameKataSandi = "KataSandi"
paramNameMasukan = "masukan"
paramNameMasukanLengkap = "masukanLengkap"
paramNamePage = "page"
paramNamePosel = "Posel"
- paramNameRequestVerificationToken = "__RequestVerificationToken"
+ paramNameRequestVerificationToken = "__RequestVerificationToken" //nolint: gosec
paramValueDasar = "dasar"
paramValueFalse = "false"
+
+ defTimeout = 20 * time.Second
)
diff --git a/kbbi_test.go b/kbbi_test.go
new file mode 100644
index 0000000..ddd4efe
--- /dev/null
+++ b/kbbi_test.go
@@ -0,0 +1,59 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package kbbi
+
+import (
+ "log"
+ "os"
+ "testing"
+)
+
+const (
+ testServerAPI = "http://127.0.0.1" + defListen
+)
+
+//nolint: gochecknoglobals
+var (
+ testServer *Server
+
+ testKataMengeja = &Kata{
+ Dasar: "eja",
+ Definisi: []*DefinisiKata{{
+ Isi: "melafalkan (menyebutkan) huruf-huruf satu demi satu",
+ Kelas: []string{"Verba: kata kerja"},
+ Contoh: []string{
+ `kita ~ kata “dapat” dengan “d-a-p-a-t”`,
+ },
+ }},
+ }
+
+ testKataEja = &Kata{
+ Definisi: []*DefinisiKata{{
+ Isi: "lafal huruf satu demi satu",
+ Kelas: []string{"Verba: kata kerja"},
+ }},
+ }
+)
+
+func TestMain(m *testing.M) {
+ var err error
+
+ // Run the local server to test the apiClient.
+ testServer, err = NewServer()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ testServer.kamus.set("mengeja", testKataMengeja)
+
+ go func() {
+ err := testServer.Start()
+ if err != nil {
+ log.Fatal(err)
+ }
+ }()
+
+ os.Exit(m.Run())
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..a241f05
--- /dev/null
+++ b/server.go
@@ -0,0 +1,167 @@
+// Copyright 2020, Shulhan <m.shulhan@gmail.com>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package kbbi
+
+import (
+ "fmt"
+ "log"
+ stdhttp "net/http"
+ "strings"
+ "time"
+
+ "github.com/shuLhan/share/lib/http"
+)
+
+const (
+ defListen = ":3394"
+ emptyResponse = "{}"
+)
+
+//
+// Server for KBBI with caching and spell checking functionalities.
+//
+type Server struct {
+ http *http.Server
+ kamus *kamusCache
+
+ // The client that forward request to official KBBI server.
+ forwardc *directClient
+
+ // If offline is true and the word definition is not found on cache,
+ // the server will not forward request to official KBBI server.
+ offline bool
+}
+
+//
+// NewServer create and initialize the server.
+//
+func NewServer() (server *Server, err error) {
+ opts := &http.ServerOptions{
+ Address: defListen,
+ }
+
+ server = &Server{}
+
+ server.kamus, err = newKamusCache("")
+ if err != nil {
+ return nil, fmt.Errorf("http.NewServer: %w", err)
+ }
+
+ server.http, err = http.NewServer(opts)
+ if err != nil {
+ return nil, fmt.Errorf("http.NewServer: %w", err)
+ }
+
+ server.forwardc, err = newDirectClient(nil)
+ if err != nil {
+ return nil, fmt.Errorf("http.NewServer: %w", err)
+ }
+
+ err = server.registerEndpoints()
+ if err != nil {
+ return nil, fmt.Errorf("http.NewServer: %w", err)
+ }
+
+ return server, nil
+}
+
+//
+// Start the HTTP server.
+//
+func (server *Server) Start() (err error) {
+ go server.dumpCache()
+
+ return server.http.Start()
+}
+
+//
+// dumpCache periodically dump the cache to file to be loaded later.
+//
+func (server *Server) dumpCache() {
+ ticker := time.NewTicker(1 * time.Hour)
+
+ for range ticker.C {
+ if !server.kamus.isChanging() {
+ continue
+ }
+ err := server.kamus.store()
+ if err != nil {
+ log.Println("server.dumpCache: ", err)
+ }
+ }
+}
+
+func (server *Server) handleDefinisi(
+ httpRes stdhttp.ResponseWriter,
+ httpReq *stdhttp.Request,
+ reqBody []byte,
+) (resBody []byte, err error) {
+ paramKata := httpReq.Form.Get(paramNameKata)
+ if len(paramKata) == 0 {
+ return []byte(emptyResponse), nil
+ }
+
+ inputs := strings.Split(paramKata, ",")
+ res := make(DefinisiResponse, len(inputs))
+
+ for _, in := range inputs {
+ in = strings.TrimSpace(in)
+ if len(in) == 0 {
+ continue
+ }
+
+ kata := server.kamus.get(in)
+ if kata != nil {
+ res[in] = kata
+ continue
+ }
+
+ if server.offline {
+ continue
+ }
+
+ // The word does not exist in cache, retrieve it from official
+ // website.
+ fwRes, err := server.forwardc.CariDefinisi([]string{in})
+ if err != nil {
+ kata.err = err
+ continue
+ }
+
+ for k, v := range fwRes {
+ if k == in {
+ res[in] = v
+ server.kamus.set(k, v)
+ delete(fwRes, k)
+ break
+ }
+ }
+ }
+
+ if len(res) == 0 {
+ return []byte(emptyResponse), nil
+ }
+
+ resBody, err = res.pack()
+ if err != nil {
+ return nil, err
+ }
+
+ return resBody, nil
+}
+
+//
+// registerEndpoints register the API endpoints.
+//
+func (server *Server) registerEndpoints() (err error) {
+ epDefinisi := &http.Endpoint{
+ Method: http.RequestMethodGet,
+ Path: pathAPIDefinisi,
+ RequestType: http.RequestTypeQuery,
+ ResponseType: http.ResponseTypeJSON,
+ Call: server.handleDefinisi,
+ }
+ return server.http.RegisterEndpoint(epDefinisi)
+}