Merge branch 'master' of https://cloud.o-forge.io/core/oc-lib
This commit is contained in:
commit
754925c32c
56
README.md
56
README.md
@ -1,5 +1,61 @@
|
|||||||
# oc-lib
|
# oc-lib
|
||||||
|
|
||||||
|
oc-lib allows read/write/search operations into the main OpenCloud databases.
|
||||||
|
|
||||||
|
It also provides common initialization and configuration utilities for all OpenCloud components
|
||||||
|
|
||||||
|
## Usage example in a beego API
|
||||||
|
|
||||||
|
```go
|
||||||
|
const appname = "oc-mycomponent"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Init the oc-lib
|
||||||
|
oclib.Init(appname)
|
||||||
|
|
||||||
|
// Load the right config file
|
||||||
|
|
||||||
|
/* The configuration loader will load the configuration from the following sources:
|
||||||
|
* - the environment variables with the prefix OCAPPNAME_ - ex: OCMYCOMPONENT_MONGOURL
|
||||||
|
* - the file /etc/oc/appname.json - ex: /etc/oc/mycomponent.json
|
||||||
|
* - the file ./appname.json - ex: ./mycomponent.json
|
||||||
|
* The configuration loader will merge the configuration from the different sources
|
||||||
|
* The configuration loader will give priority to the environment variables
|
||||||
|
* The configuration loader will give priority to the local file over the default file
|
||||||
|
*/
|
||||||
|
o := oclib.GetConfLoader()
|
||||||
|
|
||||||
|
// init the local config object
|
||||||
|
models.GetConfig().Port = o.GetIntDefault("port", 8080)
|
||||||
|
models.GetConfig().LokiUrl = o.GetStringDefault("lokiurl", "")
|
||||||
|
models.GetConfig().LogLevel = o.GetStringDefault("loglevel", "info")
|
||||||
|
models.GetConfig().MongoUrl = o.GetStringDefault("mongourl", "mongodb://127.0.0.1:27017")
|
||||||
|
models.GetConfig().MongoDatabase = o.GetStringDefault("mongodatabase", "myDb")
|
||||||
|
models.GetConfig().NatsUrl = o.GetStringDefault("natsurl", "nats://localhost:4222")
|
||||||
|
|
||||||
|
models.GetConfig().mycomponentparam1 = o.GetStringDefault("mycomponentparam1", "mycomponentdefault1")
|
||||||
|
models.GetConfig().mycomponentparam2 = o.GetStringDefault("mycomponentparam2", "mycomponentdefault2")
|
||||||
|
|
||||||
|
// feed the library with the loaded config,
|
||||||
|
// this will also initialize a logger available via oclib.GetLogger()
|
||||||
|
oclib.SetConfig(
|
||||||
|
models.GetConfig().MongoUrl
|
||||||
|
models.GetConfig().MongoDatabase
|
||||||
|
models.GetConfig().NatsUrl
|
||||||
|
models.GetConfig().LokiUrl
|
||||||
|
models.GetConfig().LogLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
// Beego init
|
||||||
|
beego.BConfig.AppName = appname
|
||||||
|
beego.BConfig.Listen.HTTPPort = models.GetConfig().Port
|
||||||
|
beego.BConfig.WebConfig.DirectoryIndex = true
|
||||||
|
beego.BConfig.WebConfig.StaticDir["/swagger"] = "swagger"
|
||||||
|
|
||||||
|
beego.Run()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## SPECIAL FLOWS IN OC-LIB RESUME :
|
## SPECIAL FLOWS IN OC-LIB RESUME :
|
||||||
|
|
||||||
### WORKFLOW AS ITS OWN WORKSPACE
|
### WORKFLOW AS ITS OWN WORKSPACE
|
||||||
|
19
config/app.go
Normal file
19
config/app.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
var appname string
|
||||||
|
|
||||||
|
// logs.CreateLogger
|
||||||
|
// Create a new logger
|
||||||
|
// Parameters:
|
||||||
|
// - appname: string : the name of the application using oclib
|
||||||
|
// - url: string : the url of a loki logger, console log only if ""
|
||||||
|
// Returns:
|
||||||
|
// - zerolog.Logger : the logger that will log for the library and the app
|
||||||
|
|
||||||
|
func SetAppName(name string) {
|
||||||
|
appname = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAppName() string {
|
||||||
|
return appname
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package tools
|
package config
|
||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
@ -14,6 +14,8 @@ type Config struct {
|
|||||||
MongoDatabase string
|
MongoDatabase string
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
|
LokiUrl string
|
||||||
|
LogLevel string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) GetUrl() string {
|
func (c Config) GetUrl() string {
|
||||||
@ -34,13 +36,20 @@ func GetConfig() *Config {
|
|||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetConfig(url string, database string, natsUrl string) *Config {
|
func SetConfig(mongoUrl string, database string, natsUrl string, lokiUrl string, logLevel string) *Config {
|
||||||
once.Do(func() {
|
/*once.Do(func() {
|
||||||
instance = &Config{
|
instance = &Config{
|
||||||
MongoUrl: url,
|
MongoUrl: mongoUrl,
|
||||||
MongoDatabase: database,
|
MongoDatabase: database,
|
||||||
NATSUrl: natsUrl,
|
NATSUrl: natsUrl,
|
||||||
|
LokiUrl: lokiUrl,
|
||||||
|
LogLevel: logLevel,
|
||||||
}
|
}
|
||||||
})
|
})*/
|
||||||
return instance
|
GetConfig().MongoUrl = mongoUrl
|
||||||
|
GetConfig().MongoDatabase = database
|
||||||
|
GetConfig().NATSUrl = natsUrl
|
||||||
|
GetConfig().LokiUrl = lokiUrl
|
||||||
|
GetConfig().LogLevel = logLevel
|
||||||
|
return GetConfig()
|
||||||
}
|
}
|
56
config/conf_loader.go
Normal file
56
config/conf_loader.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/goraz/onion"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
/* GetConfLoader
|
||||||
|
* Get the configuration loader for the application
|
||||||
|
* Parameters:
|
||||||
|
* - AppName: string : the name of the application
|
||||||
|
* Returns:
|
||||||
|
* - *onion.Onion : the configuration loader
|
||||||
|
* The configuration loader will load the configuration from the following sources:
|
||||||
|
* - the environment variables with the prefix APPNAME_
|
||||||
|
* - the file /etc/oc/appname.json
|
||||||
|
* - the file ./appname.json
|
||||||
|
* The configuration loader will merge the configuration from the different sources
|
||||||
|
* The configuration loader will give priority to the environment variables
|
||||||
|
* The configuration loader will give priority to the local file over the default file
|
||||||
|
*/
|
||||||
|
|
||||||
|
func GetConfLoader() *onion.Onion {
|
||||||
|
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
||||||
|
AppName := GetAppName()
|
||||||
|
EnvPrefix := strings.ToUpper(AppName[0:2]+AppName[3:]) + "_"
|
||||||
|
defaultConfigFile := "/etc/oc/" + AppName[3:] + ".json"
|
||||||
|
localConfigFile := "./" + AppName[3:] + ".json"
|
||||||
|
var configFile string
|
||||||
|
var o *onion.Onion
|
||||||
|
l3 := onion.NewEnvLayerPrefix("_", EnvPrefix)
|
||||||
|
l2, err := onion.NewFileLayer(localConfigFile, nil)
|
||||||
|
if err == nil {
|
||||||
|
logger.Info().Msg("Local config file found " + localConfigFile + ", overriding default file")
|
||||||
|
configFile = localConfigFile
|
||||||
|
}
|
||||||
|
l1, err := onion.NewFileLayer(defaultConfigFile, nil)
|
||||||
|
if err == nil {
|
||||||
|
logger.Info().Msg("Config file found : " + defaultConfigFile)
|
||||||
|
configFile = defaultConfigFile
|
||||||
|
}
|
||||||
|
if configFile == "" {
|
||||||
|
logger.Info().Msg("No config file found, using env")
|
||||||
|
o = onion.New(l3)
|
||||||
|
} else if l1 != nil && l2 != nil {
|
||||||
|
o = onion.New(l1, l2, l3)
|
||||||
|
} else if l1 == nil {
|
||||||
|
o = onion.New(l2, l3)
|
||||||
|
} else if l2 == nil {
|
||||||
|
o = onion.New(l1, l3)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
|
"cloud.o-forge.io/core/oc-lib/config"
|
||||||
"cloud.o-forge.io/core/oc-lib/dbs"
|
"cloud.o-forge.io/core/oc-lib/dbs"
|
||||||
"cloud.o-forge.io/core/oc-lib/dbs/mongo"
|
"cloud.o-forge.io/core/oc-lib/dbs/mongo"
|
||||||
"cloud.o-forge.io/core/oc-lib/logs"
|
"cloud.o-forge.io/core/oc-lib/logs"
|
||||||
@ -24,6 +25,7 @@ import (
|
|||||||
shared_workspace "cloud.o-forge.io/core/oc-lib/models/workspace/shared"
|
shared_workspace "cloud.o-forge.io/core/oc-lib/models/workspace/shared"
|
||||||
"cloud.o-forge.io/core/oc-lib/models/workspace/shared/rules/rule"
|
"cloud.o-forge.io/core/oc-lib/models/workspace/shared/rules/rule"
|
||||||
"cloud.o-forge.io/core/oc-lib/tools"
|
"cloud.o-forge.io/core/oc-lib/tools"
|
||||||
|
"github.com/goraz/onion"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -95,18 +97,42 @@ func AddPath(collection LibDataEnum, path string) {
|
|||||||
paths[collection] = path
|
paths[collection] = path
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init(appName string, hostname string, port string) {
|
func Init(appName string) {
|
||||||
|
config.SetAppName(appName) // set the app name to the logger to define the main log chan
|
||||||
|
// create a temporary console logger for init
|
||||||
|
logs.SetLogger(logs.CreateLogger("main"))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Expose subpackages
|
||||||
|
//
|
||||||
|
|
||||||
|
/* GetLogger returns the main logger
|
||||||
|
* @return zerolog.Logger
|
||||||
|
*/
|
||||||
|
func GetLogger() zerolog.Logger {
|
||||||
|
return logs.GetLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SetConfig will set the config and create a logger according to app configuration and initialize mongo accessor
|
||||||
|
* @param url string
|
||||||
|
* @param database string
|
||||||
|
* @param natsUrl string
|
||||||
|
* @param lokiUrl string
|
||||||
|
* @param logLevel string
|
||||||
|
* @return *Config
|
||||||
|
*/
|
||||||
|
func SetConfig(mongoUrl string, database string, natsUrl string, lokiUrl string, logLevel string) *config.Config {
|
||||||
|
cfg := config.SetConfig(mongoUrl, database, natsUrl, lokiUrl, logLevel)
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
tools.UncatchedError = append(tools.UncatchedError, errors.New("Panic recovered in Init : "+fmt.Sprintf("%v", r)+" - "+string(debug.Stack())))
|
tools.UncatchedError = append(tools.UncatchedError, errors.New("Panic recovered in Init : "+fmt.Sprintf("%v", r)+" - "+string(debug.Stack())))
|
||||||
fmt.Printf("Panic recovered in Init : %v - %v\n", r, string(debug.Stack()))
|
fmt.Printf("Panic recovered in Init : %v - %v\n", r, string(debug.Stack()))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
logs.SetAppName(appName) // set the app name to the logger to define the main log chan
|
logs.CreateLogger("main")
|
||||||
logs.SetLogger(logs.CreateLogger("main", "")) // create the logger
|
mongo.MONGOService.Init(models.GetModelsNames(), config.GetConfig()) // init the mongo service
|
||||||
tools.GetConfig().Host = hostname // set the hostname to the config for inner discovery purpose actually not used
|
|
||||||
tools.GetConfig().Port = port // set the port to the config for inner discovery purpose actually not used
|
|
||||||
mongo.MONGOService.Init(models.GetModelsNames(), tools.GetConfig()) // init the mongo service
|
|
||||||
/*
|
/*
|
||||||
Here we will check if the resource model is already stored in the database
|
Here we will check if the resource model is already stored in the database
|
||||||
If not we will store it
|
If not we will store it
|
||||||
@ -143,11 +169,33 @@ func Init(appName string, hostname string, port string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogger returns the main logger
|
/* GetConfig will get the config
|
||||||
func GetLogger() zerolog.Logger {
|
* @return *Config
|
||||||
return logs.GetLogger()
|
*/
|
||||||
|
func GetConfig() *config.Config {
|
||||||
|
return config.GetConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GetConfLoader
|
||||||
|
* Get the configuration loader for the application
|
||||||
|
* Parameters:
|
||||||
|
* - AppName: string : the name of the application
|
||||||
|
* Returns:
|
||||||
|
* - *onion.Onion : the configuration loader
|
||||||
|
* The configuration loader will load the configuration from the following sources:
|
||||||
|
* - the environment variables with the prefix OCAPPNAME_
|
||||||
|
* - the file /etc/oc/appname.json
|
||||||
|
* - the file ./appname.json
|
||||||
|
* The configuration loader will merge the configuration from the different sources
|
||||||
|
* The configuration loader will give priority to the environment variables
|
||||||
|
* The configuration loader will give priority to the local file over the default file
|
||||||
|
*/
|
||||||
|
|
||||||
|
func GetConfLoader() *onion.Onion {
|
||||||
|
return config.GetConfLoader()
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -5,11 +5,12 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cloud.o-forge.io/core/oc-lib/config"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var logger zerolog.Logger
|
var logger zerolog.Logger
|
||||||
var appname string
|
|
||||||
// logs.CreateLogger
|
// logs.CreateLogger
|
||||||
// Create a new logger
|
// Create a new logger
|
||||||
// Parameters:
|
// Parameters:
|
||||||
@ -18,14 +19,6 @@ var appname string
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - zerolog.Logger : the logger that will log for the library and the app
|
// - zerolog.Logger : the logger that will log for the library and the app
|
||||||
|
|
||||||
func SetAppName(name string) {
|
|
||||||
appname = name
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetAppName() string {
|
|
||||||
return appname
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLogger() zerolog.Logger {
|
func GetLogger() zerolog.Logger {
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
@ -34,10 +27,11 @@ func SetLogger(l zerolog.Logger) {
|
|||||||
logger = l
|
logger = l
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateLogger(funcName string, url string) zerolog.Logger {
|
func CreateLogger(funcName string) zerolog.Logger {
|
||||||
|
url := config.GetConfig().LokiUrl
|
||||||
if url != "" {
|
if url != "" {
|
||||||
labels := map[string]string{
|
labels := map[string]string{
|
||||||
"app": appname,
|
"app": config.GetAppName(),
|
||||||
"code": "go",
|
"code": "go",
|
||||||
"platform": runtime.GOOS,
|
"platform": runtime.GOOS,
|
||||||
"function": funcName,
|
"function": funcName,
|
||||||
|
@ -82,9 +82,9 @@ func (dma *AbstractAccessor) GetCaller() *tools.HTTPCaller {
|
|||||||
|
|
||||||
// Init initializes the accessor with the data type and the http caller
|
// Init initializes the accessor with the data type and the http caller
|
||||||
func (dma *AbstractAccessor) Init(t DataType, caller *tools.HTTPCaller) {
|
func (dma *AbstractAccessor) Init(t DataType, caller *tools.HTTPCaller) {
|
||||||
dma.Logger = logs.CreateLogger(t.String(), "") // Create a logger with the data type
|
dma.Logger = logs.CreateLogger(t.String()) // Create a logger with the data type
|
||||||
dma.Caller = caller // Set the caller
|
dma.Caller = caller // Set the caller
|
||||||
dma.Type = t.String() // Set the data type
|
dma.Type = t.String() // Set the data type
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenericLoadOne loads one object from the database (generic)
|
// GenericLoadOne loads one object from the database (generic)
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"cloud.o-forge.io/core/oc-lib/config"
|
||||||
"cloud.o-forge.io/core/oc-lib/dbs/mongo"
|
"cloud.o-forge.io/core/oc-lib/dbs/mongo"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -51,11 +52,11 @@ type API struct{}
|
|||||||
// GetState returns the state of the API
|
// GetState returns the state of the API
|
||||||
func (a *API) GetState() (State, int, error) {
|
func (a *API) GetState() (State, int, error) {
|
||||||
// Check if the database is up
|
// Check if the database is up
|
||||||
err := mongo.MONGOService.TestDB(GetConfig())
|
err := mongo.MONGOService.TestDB(config.GetConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DB_FALLOUT, 200, err // If the database is not up, return database fallout
|
return DB_FALLOUT, 200, err // If the database is not up, return database fallout
|
||||||
}
|
}
|
||||||
err = mongo.MONGOService.TestCollections(GetConfig(), []string{}) // Check if the collections are up
|
err = mongo.MONGOService.TestCollections(config.GetConfig(), []string{}) // Check if the collections are up
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UNPROCESSABLE_ENTITY, 200, err // If the collections are not up, return unprocessable entity
|
return UNPROCESSABLE_ENTITY, 200, err // If the collections are not up, return unprocessable entity
|
||||||
}
|
}
|
||||||
|
@ -1,53 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cloud.o-forge.io/core/oc-lib/logs"
|
|
||||||
"github.com/goraz/onion"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetConfLoader
|
|
||||||
// Get the configuration loader for the application
|
|
||||||
// Parameters:
|
|
||||||
// - AppName: string : the name of the application
|
|
||||||
// Returns:
|
|
||||||
// - *onion.Onion : the configuration loader
|
|
||||||
// The configuration loader will load the configuration from the following sources:
|
|
||||||
// - the environment variables with the prefix APPNAME_
|
|
||||||
// - the file /etc/oc/appname.json
|
|
||||||
// - the file ./appname.json
|
|
||||||
// The configuration loader will merge the configuration from the different sources
|
|
||||||
// The configuration loader will give priority to the environment variables
|
|
||||||
// The configuration loader will give priority to the local file over the default file
|
|
||||||
|
|
||||||
func GetConfLoader(AppName string) *onion.Onion {
|
|
||||||
logger := logs.GetLogger()
|
|
||||||
EnvPrefix := strings.ToUpper(AppName[0:2]+AppName[3:]) + "_"
|
|
||||||
defaultConfigFile := "/etc/oc/" + AppName[0:2] + ".json"
|
|
||||||
localConfigFile := "./" + AppName[0:2] + ".json"
|
|
||||||
var configFile string
|
|
||||||
var o *onion.Onion
|
|
||||||
l3 := onion.NewEnvLayerPrefix("_", EnvPrefix)
|
|
||||||
l2, err := onion.NewFileLayer(localConfigFile, nil)
|
|
||||||
if err == nil {
|
|
||||||
logger.Info().Msg("Local config file found " + localConfigFile + ", overriding default file")
|
|
||||||
configFile = localConfigFile
|
|
||||||
}
|
|
||||||
l1, err := onion.NewFileLayer(defaultConfigFile, nil)
|
|
||||||
if err == nil {
|
|
||||||
logger.Info().Msg("Config file found : " + defaultConfigFile)
|
|
||||||
configFile = defaultConfigFile
|
|
||||||
}
|
|
||||||
if configFile == "" {
|
|
||||||
logger.Info().Msg("No config file found, using env")
|
|
||||||
o = onion.New(l3)
|
|
||||||
} else if l1 != nil && l2 != nil {
|
|
||||||
o = onion.New(l1, l2, l3)
|
|
||||||
} else if l1 == nil {
|
|
||||||
o = onion.New(l2, l3)
|
|
||||||
} else if l2 == nil {
|
|
||||||
o = onion.New(l1, l3)
|
|
||||||
}
|
|
||||||
return o
|
|
||||||
}
|
|
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cloud.o-forge.io/core/oc-lib/config"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -44,10 +45,10 @@ func NewNATSCaller() *natsCaller {
|
|||||||
|
|
||||||
// SetNATSPub sets a message to the NATS server
|
// SetNATSPub sets a message to the NATS server
|
||||||
func (o *natsCaller) SetNATSPub(dataName string, method NATSMethod, data interface{}) string {
|
func (o *natsCaller) SetNATSPub(dataName string, method NATSMethod, data interface{}) string {
|
||||||
if GetConfig().NATSUrl == "" {
|
if config.GetConfig().NATSUrl == "" {
|
||||||
return " -> NATS_SERVER is not set"
|
return " -> NATS_SERVER is not set"
|
||||||
}
|
}
|
||||||
nc, err := nats.Connect(GetConfig().NATSUrl)
|
nc, err := nats.Connect(config.GetConfig().NATSUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return " -> Could not reach NATS server : " + err.Error()
|
return " -> Could not reach NATS server : " + err.Error()
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user