github.com/vmware/transport-go@v1.3.4/plank/services/stock-ticker-service.go (about)

     1  // Copyright 2019-2021 VMware, Inc.
     2  // SPDX-License-Identifier: BSD-2-Clause
     3  
     4  package services
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"io/ioutil"
    10  	"net/http"
    11  	"net/url"
    12  	"sync"
    13  	"time"
    14  
    15  	"github.com/google/uuid"
    16  	"github.com/gorilla/mux"
    17  	"github.com/vmware/transport-go/bus"
    18  	"github.com/vmware/transport-go/model"
    19  	"github.com/vmware/transport-go/plank/utils"
    20  	"github.com/vmware/transport-go/service"
    21  	"github.com/vmware/transport-go/stompserver"
    22  	"golang.org/x/net/context/ctxhttp"
    23  )
    24  
    25  const (
    26  	StockTickerServiceChannel = "stock-ticker-service"
    27  	StockTickerAPI            = "https://finnhub.io/api/v1/quote"
    28  )
    29  
    30  // TickerSnapshotData is the data structure for this demo service
    31  type TickerSnapshotData struct {
    32  	CurrentPrice       float64 `json:"c"`
    33  	Change             float64 `json:"d"`
    34  	PercentChange      float64 `json:"dp"`
    35  	HighDayPrice       float64 `json:"h"`
    36  	LowDayPrice        float64 `json:"l"`
    37  	OpenPrice          float64 `json:"o"`
    38  	PreviousClosePrice float64 `json:"pc"`
    39  	LastUpdated        int64   `json:"t"`
    40  }
    41  
    42  // StockTickerService is a more complex real life example where its job is to subscribe clients
    43  // to price updates for a stock symbol. the service accepts a JSON-formatted request from the client
    44  // that must be formatted like this: {"symbol": "<TICKER_SYMBOL>"}.
    45  //
    46  // once the service receives the request, it will schedule a job to query the stock price API
    47  // for the provided symbol, retrieve the data and pipe it back to the client every thirty seconds.
    48  // upon the connected client leaving, the service will remove from its cache the timer.
    49  type StockTickerService struct {
    50  	tickerListenersMap map[string]*time.Ticker
    51  	lock               sync.RWMutex
    52  }
    53  
    54  // NewStockTickerService returns a new instance of StockTickerService
    55  func NewStockTickerService() *StockTickerService {
    56  	return &StockTickerService{
    57  		tickerListenersMap: make(map[string]*time.Ticker),
    58  	}
    59  }
    60  
    61  // HandleServiceRequest accepts incoming requests and schedules a job to fetch stock price from
    62  // a third party API and return the results back to the user.
    63  func (ps *StockTickerService) HandleServiceRequest(request *model.Request, core service.FabricServiceCore) {
    64  	switch request.Request {
    65  	case "ticker_price_lookup":
    66  		input := request.Payload.(map[string]string)
    67  		response, err := queryStockTickerAPI(input["symbol"])
    68  		if err != nil {
    69  			core.SendErrorResponse(request, 400, err.Error())
    70  			return
    71  		}
    72  		// send the response back to the client
    73  		core.SendResponse(request, response)
    74  		break
    75  
    76  	case "ticker_price_update_stream":
    77  		// parse the request and extract user input from key "symbol"
    78  		input := request.Payload.(map[string]interface{})
    79  		symbol := input["symbol"].(string)
    80  
    81  		// get the price immediately for the first request
    82  		response, err := queryStockTickerAPI(symbol)
    83  		if err != nil {
    84  			core.SendErrorResponse(request, 400, err.Error())
    85  			return
    86  		}
    87  		// send the response back to the client
    88  		core.SendResponse(request, response)
    89  
    90  		// set a ticker that fires every 30 seconds and keep it in a map for later disposal
    91  		ps.lock.Lock()
    92  		ticker := time.NewTicker(30 * time.Second)
    93  		ps.tickerListenersMap[request.BrokerDestination.ConnectionId] = ticker
    94  		ps.lock.Unlock()
    95  
    96  		// set up a handler for every time a ticker fires.
    97  		go func() {
    98  			for {
    99  				select {
   100  				case <-ticker.C:
   101  					response, err = queryStockTickerAPI(symbol)
   102  					if err != nil {
   103  						core.SendErrorResponse(request, 500, err.Error())
   104  						continue
   105  					}
   106  
   107  					// log message to demonstrate that once the client disconnects
   108  					// the server disposes of the ticker to prevent memory leak.
   109  					utils.Log.Infoln("sending...")
   110  
   111  					// send the response back to the client
   112  					core.SendResponse(request, response)
   113  				}
   114  			}
   115  		}()
   116  	default:
   117  		core.HandleUnknownRequest(request)
   118  	}
   119  }
   120  
   121  // OnServiceReady sets up a listener to monitor the client STOMP sessions disconnecting from
   122  // their sessions so that it can stop the running ticker and destroy it from the map structure
   123  // for individual disconnected clients. this will prevent the service from making unnecessary
   124  // HTTP calls for the clients even after they are gone and also the memory consumed from
   125  // ever growing with each connection.
   126  func (ps *StockTickerService) OnServiceReady() chan bool {
   127  	sessionNotifyHandler, _ := bus.GetBus().ListenStream(bus.STOMP_SESSION_NOTIFY_CHANNEL)
   128  	sessionNotifyHandler.Handle(func(message *model.Message) {
   129  		stompSessionEvt := message.Payload.(*bus.StompSessionEvent)
   130  		if stompSessionEvt.EventType == stompserver.ConnectionClosed ||
   131  			stompSessionEvt.EventType == stompserver.UnsubscribeFromTopic {
   132  			if ticker, exists := ps.tickerListenersMap[stompSessionEvt.Id]; exists {
   133  				ticker.Stop()
   134  				ps.lock.Lock()
   135  				delete(ps.tickerListenersMap, stompSessionEvt.Id)
   136  				ps.lock.Unlock()
   137  				utils.Log.Warnf("timer cleaned for %s. trigger: %v", stompSessionEvt.Id, stompSessionEvt.EventType)
   138  			}
   139  		}
   140  	}, func(err error) {})
   141  	readyChan := make(chan bool, 1)
   142  	readyChan <- true
   143  	return readyChan
   144  }
   145  
   146  // OnServerShutdown removes the running tickers
   147  func (ps *StockTickerService) OnServerShutdown() {
   148  	ps.lock.Lock()
   149  	defer ps.lock.Unlock()
   150  
   151  	for _, ticker := range ps.tickerListenersMap {
   152  		ticker.Stop()
   153  	}
   154  	return
   155  }
   156  
   157  // GetRESTBridgeConfig returns a config for a REST endpoint that performs the same action as the STOMP variant
   158  // except that there will be only one response instead of every 30 seconds.
   159  func (ps *StockTickerService) GetRESTBridgeConfig() []*service.RESTBridgeConfig {
   160  	return []*service.RESTBridgeConfig{
   161  		{
   162  			ServiceChannel: StockTickerServiceChannel,
   163  			Uri:            "/rest/stock-ticker/{symbol}",
   164  			Method:         http.MethodGet,
   165  			AllowHead:      true,
   166  			AllowOptions:   true,
   167  			FabricRequestBuilder: func(w http.ResponseWriter, r *http.Request) model.Request {
   168  				pathParams := mux.Vars(r)
   169  				return model.Request{
   170  					Id:                &uuid.UUID{},
   171  					Payload:           map[string]string{"symbol": pathParams["symbol"]},
   172  					Request:           "ticker_price_lookup",
   173  					BrokerDestination: nil,
   174  				}
   175  			},
   176  		},
   177  	}
   178  }
   179  
   180  // newTickerRequest is a convenient function that takes symbol as an input and returns
   181  // a new HTTP request object along with any error
   182  func newTickerRequest(symbol string) (*http.Request, error) {
   183  	uv := url.Values{}
   184  	uv.Set("symbol", symbol)
   185  
   186  	req, err := http.NewRequest("GET", StockTickerAPI, nil)
   187  	if err != nil {
   188  		return nil, err
   189  	}
   190  	req.URL.RawQuery = uv.Encode()
   191  	req.Header.Set("X-Finnhub-Token", "sandbox_c4l951aad3iftk6rfja0")
   192  	return req, nil
   193  }
   194  
   195  // queryStockTickerAPI performs an HTTP request against the Stock Ticker API and returns the results
   196  // as a generic map[string]interface{} structure. if there's any error during the request-response cycle
   197  // a nil will be returned followed by an error object.
   198  func queryStockTickerAPI(symbol string) (map[string]interface{}, error) {
   199  	// craft a new HTTP request for the stock price provider API
   200  	req, err := newTickerRequest(symbol)
   201  	if err != nil {
   202  		return nil, err
   203  	}
   204  
   205  	// perform an HTTP call
   206  	rsp, err := ctxhttp.Do(context.Background(), http.DefaultClient, req)
   207  	if err != nil {
   208  		return nil, err
   209  	}
   210  
   211  	// parse the response from the HTTP call
   212  	defer rsp.Body.Close()
   213  	tickerData := &TickerSnapshotData{}
   214  	b, err := ioutil.ReadAll(rsp.Body)
   215  	if err != nil {
   216  		return nil, err
   217  	}
   218  
   219  	if err = json.Unmarshal(b, tickerData); err != nil {
   220  		return nil, err
   221  	}
   222  
   223  	return map[string]interface{}{
   224  		"symbol":             symbol,
   225  		"lastRefreshed":      time.Unix(tickerData.LastUpdated, 0).String(),
   226  		"currentPrice":       tickerData.CurrentPrice,
   227  		"change":             tickerData.Change,
   228  		"highDayPrice":       tickerData.HighDayPrice,
   229  		"lowDayPrice":        tickerData.LowDayPrice,
   230  		"openPrice":          tickerData.OpenPrice,
   231  		"closePrice":         tickerData.PreviousClosePrice, // for backward compatible with UI examples
   232  		"previousClosePrice": tickerData.PreviousClosePrice,
   233  	}, nil
   234  }