From 69551eba0d3e405010393bd724ebb29b7a73440d Mon Sep 17 00:00:00 2001 From: Joe Shaw Date: Thu, 16 Nov 2023 08:48:07 -0500 Subject: [PATCH] add a new `device` package for device detection Calling `device.Lookup()` returns a `Device` struct with accessor methods to get information on the user agent such as name/brand/model as well as booleans for common device types. --- .github/workflows/integration-tests.yml | 2 +- CHANGELOG.md | 6 + _examples/device-detection/fastly.toml | 9 ++ _examples/device-detection/main.go | 23 +++ device/device_detection.go | 142 ++++++++++++++++++ .../device_detection/fastly.toml | 21 +++ .../device_detection/main_test.go | 99 ++++++++++++ internal/abi/fastly/hostcalls_guest.go | 52 +++++++ internal/abi/fastly/hostcalls_noguest.go | 4 + 9 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 _examples/device-detection/fastly.toml create mode 100644 _examples/device-detection/main.go create mode 100644 device/device_detection.go create mode 100644 integration_tests/device_detection/fastly.toml create mode 100644 integration_tests/device_detection/main_test.go diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 44cbc36..f9f6533 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,7 +1,7 @@ name: Integration Tests on: [push] env: - VICEROY_VERSION: 0.9.2 + VICEROY_VERSION: 0.9.3 jobs: integration-tests-tinygo: strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb796e..10ff478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Added + +- Add support for device detection (`device`) + ## 1.1.0 (2023-10-31) - Improve error handling and documentation in `kvstore` package diff --git a/_examples/device-detection/fastly.toml b/_examples/device-detection/fastly.toml new file mode 100644 index 0000000..6535a81 --- /dev/null +++ b/_examples/device-detection/fastly.toml @@ -0,0 +1,9 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://developer.fastly.com/reference/fastly-toml/ + +authors = ["oss@fastly.com"] +description = "" +language = "go" +manifest_version = 2 +name = "device-detection" +service_id = "" diff --git a/_examples/device-detection/main.go b/_examples/device-detection/main.go new file mode 100644 index 0000000..6c55f8b --- /dev/null +++ b/_examples/device-detection/main.go @@ -0,0 +1,23 @@ +// Copyright 2022 Fastly, Inc. + +package main + +import ( + "context" + "fmt" + + "github.com/fastly/compute-sdk-go/device" + "github.com/fastly/compute-sdk-go/fsthttp" +) + +func main() { + fsthttp.ServeFunc(func(ctx context.Context, w fsthttp.ResponseWriter, r *fsthttp.Request) { + d, err := device.Lookup(r.Header.Get("User-Agent")) + if err != nil { + fsthttp.Error(w, err.Error(), fsthttp.StatusInternalServerError) + return + } + + fmt.Fprintf(w, "%+v\n", d) + }) +} diff --git a/device/device_detection.go b/device/device_detection.go new file mode 100644 index 0000000..d833b03 --- /dev/null +++ b/device/device_detection.go @@ -0,0 +1,142 @@ +// Package device provides device dection based on the User-Agent +// header. +package device + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/fastly/compute-sdk-go/internal/abi/fastly" +) + +var ( + // ErrDeviceNotFound is returned when the device is not found. + ErrDeviceNotFound = errors.New("device not found") + + // ErrUnexpected indicates that an unexpected error occurred. + ErrUnexpected = errors.New("unexpected error") +) + +type Device struct { + info deviceInfo +} + +type deviceInfo struct { + Device struct { + Name string `json:"name"` + Brand string `json:"brand"` + Model string `json:"model"` + HWType string `json:"hwtype"` + IsEReader bool `json:"is_ereader"` + IsGameConsole bool `json:"is_gameconsole"` + IsMediaPlayer bool `json:"is_mediaplayer"` + IsMobile bool `json:"is_mobile"` + IsSmartTV bool `json:"is_smarttv"` + IsTablet bool `json:"is_tablet"` + IsTVPlayer bool `json:"is_tvplayer"` + IsDesktop bool `json:"is_desktop"` + IsTouchscreen bool `json:"is_touchscreen"` + } `json:"device"` +} + +func Lookup(userAgent string) (Device, error) { + var d Device + + raw, err := fastly.DeviceLookup(userAgent) + if err != nil { + status, ok := fastly.IsFastlyError(err) + switch { + case ok && status == fastly.FastlyStatusNone: + return d, ErrDeviceNotFound + case ok: + return d, fmt.Errorf("%w (%s)", ErrUnexpected, status) + default: + return d, err + } + } + + if err := json.Unmarshal(raw, &d.info); err != nil { + return d, err + } + + return d, nil +} + +// Name returns the name of the client device. +func (d *Device) Name() string { + return d.info.Device.Name +} + +// Brand returns the brand of the client device, possibly different from +// the manufacturer of that device. +func (d *Device) Brand() string { + return d.info.Device.Brand +} + +// Model returns the model of the client device. +func (d *Device) Model() string { + return d.info.Device.Model +} + +// HWType returns a string representation of the primary client platform +// hardware. The most commonly used device types are also identified +// via boolean variables. Because a device may have multiple device +// types and this variable only has the primary type, we recommend using +// the boolean variables for logic and using this string representation +// for logging. +func (d *Device) HWType() string { + return d.info.Device.HWType +} + +// IsEReader returns true if the client device is a reading device (like +// a Kindle). +func (d *Device) IsEReader() bool { + return d.info.Device.IsEReader +} + +// IsGameConsole returns true if the client device is a video game +// console (like a PlayStation or Xbox). +func (d *Device) IsGameConsole() bool { + return d.info.Device.IsGameConsole +} + +// IsMediaPlayer returns true if the client device is a media player +// (like Blu-ray players, iPod devices, and smart speakers such as +// Amazon Echo). +func (d *Device) IsMediaPlayer() bool { + return d.info.Device.IsMediaPlayer +} + +// IsMobile returns true if the client device is a mobile phone. +func (d *Device) IsMobile() bool { + return d.info.Device.IsMobile +} + +// IsSmartTV returns true if the client device is a smart TV. +func (d *Device) IsSmartTV() bool { + return d.info.Device.IsSmartTV +} + +// IsTablet returns true if the client device is a tablet (like an +// iPad). +func (d *Device) IsTablet() bool { + return d.info.Device.IsTablet +} + +// IsTVPlayer returns true if the client device is a set-top box or +// other TV player (like a Roku or Apple TV). +func (d *Device) IsTVPlayer() bool { + return d.info.Device.IsTVPlayer +} + +// IsDesktop returns true if the client device is a desktop web browser. +func (d *Device) IsDesktop() bool { + return d.info.Device.IsDesktop +} + +// IsTouchscreen returns true if the client device's screen is touch +// sensitive. +func (d *Device) IsTouchscreen() bool { + return d.info.Device.IsTouchscreen +} diff --git a/integration_tests/device_detection/fastly.toml b/integration_tests/device_detection/fastly.toml new file mode 100644 index 0000000..a2b5896 --- /dev/null +++ b/integration_tests/device_detection/fastly.toml @@ -0,0 +1,21 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://developer.fastly.com/reference/fastly-toml/ + +authors = ["oss@fastly.com"] +description = "" +language = "go" +manifest_version = 2 +name = "geolocation" +service_id = "" + + +[local_server] +[local_server.device_detection] +format = "inline-toml" + +[local_server.device_detection.user_agents] +[local_server.device_detection.user_agents."Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0 [FBAN/FBIOS;FBAV/8.0.0.28.18;FBBV/1665515;FBDV/iPhone4,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/7.0.4;FBSS/2; FBCR/Telekom.de;FBID/phone;FBLC/de_DE;FBOP/5]"] +device = {name = "iPhone", brand = "Apple", model = "iPhone4,1", hwtype = "Mobile Phone", is_ereader = false, is_gameconsole = false, is_mediaplayer = false, is_mobile = true, is_smarttv = false, is_tablet = false, is_tvplayer = false, is_desktop = false, is_touchscreen = true} + +[local_server.device_detection.user_agents."ghosts-app/1.0.2.1 (ASUSTeK COMPUTER INC.; X550CC; Windows 8 (X86); en)"] +device = {name = "Asus TeK", brand = "Asus", model = "TeK", is_desktop = false} diff --git a/integration_tests/device_detection/main_test.go b/integration_tests/device_detection/main_test.go new file mode 100644 index 0000000..9f0e38b --- /dev/null +++ b/integration_tests/device_detection/main_test.go @@ -0,0 +1,99 @@ +//go:build ((tinygo.wasm && wasi) || wasip1) && !nofastlyhostcalls + +// Copyright 2023 Fastly, Inc. + +package main + +import ( + "context" + "fmt" + "testing" + + "github.com/fastly/compute-sdk-go/device" + "github.com/fastly/compute-sdk-go/fsthttp" + "github.com/fastly/compute-sdk-go/fsttest" +) + +func assert[T comparable](res fsthttp.ResponseWriter, field string, got, want T) { + if got != want { + fsthttp.Error(res, fmt.Sprintf("%s: got %v, want %v", field, got, want), fsthttp.StatusInternalServerError) + } +} + +func TestDeviceDetection(t *testing.T) { + handler := func(ctx context.Context, res fsthttp.ResponseWriter, req *fsthttp.Request) { + d, err := device.Lookup(req.Header.Get("User-Agent")) + + switch req.URL.Path { + case "/iPhone": + if err != nil { + fsthttp.Error(res, err.Error(), fsthttp.StatusInternalServerError) + return + } + + assert(res, "Name", d.Name(), "iPhone") + assert(res, "Brand", d.Brand(), "Apple") + assert(res, "Model", d.Model(), "iPhone4,1") + assert(res, "HWType", d.HWType(), "Mobile Phone") + assert(res, "IsMobile", d.IsMobile(), true) + assert(res, "IsTouchscreen", d.IsTouchscreen(), true) + + case "/AsusTeK": + if err != nil { + fsthttp.Error(res, err.Error(), fsthttp.StatusInternalServerError) + return + } + + assert(res, "Name", d.Name(), "Asus TeK") + assert(res, "Brand", d.Brand(), "Asus") + assert(res, "Model", d.Model(), "TeK") + + case "/unknown": + if err != device.ErrDeviceNotFound { + fsthttp.Error(res, err.Error(), fsthttp.StatusInternalServerError) + return + } + + default: + fsthttp.Error(res, "not found", fsthttp.StatusNotFound) + } + } + + testcases := []struct { + name string + userAgent string + }{ + { + name: "iPhone", + userAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0 [FBAN/FBIOS;FBAV/8.0.0.28.18;FBBV/1665515;FBDV/iPhone4,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/7.0.4;FBSS/2; FBCR/Telekom.de;FBID/phone;FBLC/de_DE;FBOP/5]", + }, + + { + name: "AsusTeK", + userAgent: "ghosts-app/1.0.2.1 (ASUSTeK COMPUTER INC.; X550CC; Windows 8 (X86); en)", + }, + + { + name: "unknown", + userAgent: "whoopty doopty doo", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + r, err := fsthttp.NewRequest("GET", "/"+tc.name, nil) + if err != nil { + t.Fatal(err) + } + r.Header.Set("User-Agent", tc.userAgent) + w := fsttest.NewRecorder() + + handler(context.Background(), w, r) + + if got, want := w.Code, fsthttp.StatusOK; got != want { + t.Errorf("got %v, want %v", got, want) + t.Error(w.Body.String()) + } + }) + } +} diff --git a/internal/abi/fastly/hostcalls_guest.go b/internal/abi/fastly/hostcalls_guest.go index 1e218dd..5ff82b9 100644 --- a/internal/abi/fastly/hostcalls_guest.go +++ b/internal/abi/fastly/hostcalls_guest.go @@ -3387,3 +3387,55 @@ func PurgeSurrogateKey(surrogateKey string, opts PurgeOptions) error { prim.ToPointer(&opts.opts), ).toError() } + +// witx: +// +// (module $fastly_device_detection +// (@interface func (export "lookup") +// (param $user_agent string) +// +// (param $buf (@witx pointer (@witx char8))) +// (param $buf_len (@witx usize)) +// (param $nwritten_out (@witx pointer (@witx usize))) +// (result $err (expected (error $fastly_status))) +// ) +// ) +// +//go:wasmimport fastly_device_detection lookup +//go:noescape +func fastlyDeviceDetectionLookup( + userAgentData prim.Pointer[prim.U8], userAgentLen prim.Usize, + buf prim.Pointer[prim.Char8], + bufLen prim.Usize, + nWritten prim.Pointer[prim.Usize], +) FastlyStatus + +func DeviceLookup(userAgent string) ([]byte, error) { + buf := prim.NewWriteBuffer(defaultBufferLen) + + userAgentBuffer := prim.NewReadBufferFromString(userAgent).Wstring() + + status := fastlyDeviceDetectionLookup( + userAgentBuffer.Data, userAgentBuffer.Len, + prim.ToPointer(buf.Char8Pointer()), + buf.Cap(), + prim.ToPointer(buf.NPointer()), + ) + if status == FastlyStatusBufLen { + // The buffer was too small, but it'll tell us how big it will + // need to be in order to fit the content. + buf = prim.NewWriteBuffer(int(buf.NValue())) + + status = fastlyDeviceDetectionLookup( + userAgentBuffer.Data, userAgentBuffer.Len, + prim.ToPointer(buf.Char8Pointer()), + buf.Cap(), + prim.ToPointer(buf.NPointer()), + ) + } + if err := status.toError(); err != nil { + return nil, err + } + + return buf.AsBytes(), nil +} diff --git a/internal/abi/fastly/hostcalls_noguest.go b/internal/abi/fastly/hostcalls_noguest.go index 0c6bd41..0b9a1ad 100644 --- a/internal/abi/fastly/hostcalls_noguest.go +++ b/internal/abi/fastly/hostcalls_noguest.go @@ -468,3 +468,7 @@ func (o *PurgeOptions) SoftPurge(v bool) error { func PurgeSurrogateKey(surrogateKey string, opts PurgeOptions) error { return fmt.Errorf("not implemented") } + +func DeviceLookup(userAgent string) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +}