diff --git a/frontend/src/AppRoutes/utils.ts b/frontend/src/AppRoutes/utils.ts index 698629d325..68df5073e4 100644 --- a/frontend/src/AppRoutes/utils.ts +++ b/frontend/src/AppRoutes/utils.ts @@ -57,6 +57,7 @@ const afterLogin = async ( profilePictureURL: payload.profilePictureURL, userId: payload.id, orgId: payload.orgId, + userFlags: payload.flags, }, }); diff --git a/frontend/src/api/user/setFlags.ts b/frontend/src/api/user/setFlags.ts new file mode 100644 index 0000000000..0ae9b1855d --- /dev/null +++ b/frontend/src/api/user/setFlags.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/setFlags'; + +const setFlags = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.patch(`/user/${props.userId}/flags`, { + ...props.flags, + }); + + return { + statusCode: 200, + error: null, + message: response.data?.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default setFlags; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts index fdfa6c32a4..f74c1c6831 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -41,6 +41,7 @@ export const Logout = (): void => { orgName: '', profilePictureURL: '', userId: '', + userFlags: {}, }, }); diff --git a/frontend/src/components/MessageTip/index.tsx b/frontend/src/components/MessageTip/index.tsx new file mode 100644 index 0000000000..67cdb41703 --- /dev/null +++ b/frontend/src/components/MessageTip/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { StyledAlert } from './styles'; + +interface MessageTipProps { + show?: boolean; + message: React.ReactNode | string; + action: React.ReactNode | undefined; +} + +function MessageTip({ + show, + message, + action, +}: MessageTipProps): JSX.Element | null { + if (!show) return null; + + return ( + + ); +} + +MessageTip.defaultProps = { + show: false, +}; + +export default MessageTip; diff --git a/frontend/src/components/MessageTip/styles.ts b/frontend/src/components/MessageTip/styles.ts new file mode 100644 index 0000000000..2bf574092c --- /dev/null +++ b/frontend/src/components/MessageTip/styles.ts @@ -0,0 +1,6 @@ +import { Alert } from 'antd'; +import styled from 'styled-components'; + +export const StyledAlert = styled(Alert)` + align-items: center; +`; diff --git a/frontend/src/components/ReleaseNote/ReleaseNoteProps.ts b/frontend/src/components/ReleaseNote/ReleaseNoteProps.ts new file mode 100644 index 0000000000..f2407592cb --- /dev/null +++ b/frontend/src/components/ReleaseNote/ReleaseNoteProps.ts @@ -0,0 +1,4 @@ +export default interface ReleaseNoteProps { + path?: string; + release?: string; +} diff --git a/frontend/src/components/ReleaseNote/Releases/ReleaseNote0120.tsx b/frontend/src/components/ReleaseNote/Releases/ReleaseNote0120.tsx new file mode 100644 index 0000000000..78b2800b61 --- /dev/null +++ b/frontend/src/components/ReleaseNote/Releases/ReleaseNote0120.tsx @@ -0,0 +1,73 @@ +import { Button, Space } from 'antd'; +import setFlags from 'api/user/setFlags'; +import MessageTip from 'components/MessageTip'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Dispatch } from 'redux'; +import { AppState } from 'store/reducers'; +import AppActions from 'types/actions'; +import { UPDATE_USER_FLAG } from 'types/actions/app'; +import { UserFlags } from 'types/api/user/setFlags'; +import AppReducer from 'types/reducer/app'; + +import ReleaseNoteProps from '../ReleaseNoteProps'; + +export default function ReleaseNote0120({ + release, +}: ReleaseNoteProps): JSX.Element | null { + const { user } = useSelector((state) => state.app); + + const dispatch = useDispatch>(); + + const handleDontShow = useCallback(async (): Promise => { + const flags: UserFlags = { ReleaseNote0120Hide: 'Y' }; + + try { + dispatch({ + type: UPDATE_USER_FLAG, + payload: { + flags, + }, + }); + if (!user) { + // no user is set, so escape the routine + return; + } + + const response = await setFlags({ userId: user?.userId, flags }); + + if (response.statusCode !== 200) { + console.log('failed to complete do not show status', response.error); + } + } catch (e) { + // here we do not nothing as the cost of error is minor, + // the user can switch the do no show option again in the further. + console.log('unexpected error: failed to complete do not show status', e); + } + }, [dispatch, user]); + + return ( + + You are using {release} of SigNoz. We have introduced distributed setup in + v0.12.0 release. If you use or plan to use clickhouse queries in dashboard + or alerts, you might want to read about querying the new distributed tables{' '} + + here + + + } + action={ + + + + } + /> + ); +} diff --git a/frontend/src/components/ReleaseNote/index.tsx b/frontend/src/components/ReleaseNote/index.tsx new file mode 100644 index 0000000000..715eba724b --- /dev/null +++ b/frontend/src/components/ReleaseNote/index.tsx @@ -0,0 +1,66 @@ +import ReleaseNoteProps from 'components/ReleaseNote/ReleaseNoteProps'; +import ReleaseNote0120 from 'components/ReleaseNote/Releases/ReleaseNote0120'; +import ROUTES from 'constants/routes'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { UserFlags } from 'types/api/user/setFlags'; +import AppReducer from 'types/reducer/app'; + +interface ComponentMapType { + match: ( + path: string | undefined, + version: string, + userFlags: UserFlags | null, + ) => boolean; + component: ({ path, release }: ReleaseNoteProps) => JSX.Element | null; +} + +const allComponentMap: ComponentMapType[] = [ + { + match: ( + path: string | undefined, + version: string, + userFlags: UserFlags | null, + ): boolean => { + if (!path) { + return false; + } + const allowedPaths = [ + ROUTES.LIST_ALL_ALERT, + ROUTES.APPLICATION, + ROUTES.ALL_DASHBOARD, + ]; + return ( + userFlags?.ReleaseNote0120Hide !== 'Y' && + allowedPaths.includes(path) && + version.startsWith('v0.12') + ); + }, + component: ReleaseNote0120, + }, +]; + +// ReleaseNote prints release specific warnings and notes that +// user needs to be aware of before using the upgraded version. +function ReleaseNote({ path }: ReleaseNoteProps): JSX.Element | null { + const { userFlags, currentVersion } = useSelector( + (state) => state.app, + ); + + const c = allComponentMap.find((item) => { + return item.match(path, currentVersion, userFlags); + }); + + if (!c) { + return null; + } + + return ; +} + +ReleaseNote.defaultProps = { + path: '', +}; + +export default ReleaseNote; diff --git a/frontend/src/container/ListAlertRules/index.tsx b/frontend/src/container/ListAlertRules/index.tsx index 2b81729e7e..7af7bad976 100644 --- a/frontend/src/container/ListAlertRules/index.tsx +++ b/frontend/src/container/ListAlertRules/index.tsx @@ -1,14 +1,17 @@ -import { notification } from 'antd'; +import { notification, Space } from 'antd'; import getAll from 'api/alerts/getAll'; +import ReleaseNote from 'components/ReleaseNote'; import Spinner from 'components/Spinner'; import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; +import { useLocation } from 'react-router-dom'; import ListAlert from './ListAlert'; function ListAlertRules(): JSX.Element { const { t } = useTranslation('common'); + const location = useLocation(); const { data, isError, isLoading, refetch, status } = useQuery('allAlerts', { queryFn: getAll, cacheTime: 0, @@ -45,12 +48,15 @@ function ListAlertRules(): JSX.Element { } return ( - + + + + ); } diff --git a/frontend/src/container/MySettings/UpdateName/index.tsx b/frontend/src/container/MySettings/UpdateName/index.tsx index 0b5fd501c7..fccb5a7285 100644 --- a/frontend/src/container/MySettings/UpdateName/index.tsx +++ b/frontend/src/container/MySettings/UpdateName/index.tsx @@ -12,7 +12,7 @@ import AppReducer from 'types/reducer/app'; import { NameInput } from '../styles'; function UpdateName(): JSX.Element { - const { user, role, org } = useSelector( + const { user, role, org, userFlags } = useSelector( (state) => state.app, ); const { t } = useTranslation(); @@ -47,6 +47,7 @@ function UpdateName(): JSX.Element { ROLE: role || 'ADMIN', orgId: org[0].id, orgName: org[0].name, + userFlags: userFlags || {}, }, }); } else { diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index f23945cba7..4380070228 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -1,17 +1,26 @@ +import { Space } from 'antd'; +import ReleaseNote from 'components/ReleaseNote'; import ListOfAllDashboard from 'container/ListOfDashboard'; import React, { useEffect } from 'react'; import { connect } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { GetAllDashboards } from 'store/actions'; import AppActions from 'types/actions'; function Dashboard({ getAllDashboards }: DashboardProps): JSX.Element { + const location = useLocation(); useEffect(() => { getAllDashboards(); }, [getAllDashboards]); - return ; + return ( + + + + + ); } interface DispatchProps { diff --git a/frontend/src/pages/Metrics/index.tsx b/frontend/src/pages/Metrics/index.tsx index a47229ad5c..a83aaef5ae 100644 --- a/frontend/src/pages/Metrics/index.tsx +++ b/frontend/src/pages/Metrics/index.tsx @@ -1,5 +1,6 @@ -import { notification } from 'antd'; +import { notification, Space } from 'antd'; import getLocalStorageKey from 'api/browser/localstorage/get'; +import ReleaseNote from 'components/ReleaseNote'; import Spinner from 'components/Spinner'; import { SKIP_ONBOARDING } from 'constants/onboarding'; import ResourceAttributesFilter from 'container/MetricsApplication/ResourceAttributesFilter'; @@ -7,6 +8,7 @@ import MetricTable from 'container/MetricsTable'; import { convertRawQueriesToTraceSelectedTags } from 'lib/resourceAttributes'; import React, { useEffect, useMemo } from 'react'; import { connect, useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { bindActionCreators, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { GetService, GetServiceProps } from 'store/actions/metrics'; @@ -21,6 +23,7 @@ function Metrics({ getService }: MetricsProps): JSX.Element { AppState, GlobalReducer >((state) => state.globalTime); + const location = useLocation(); const { services, resourceAttributeQueries, @@ -86,10 +89,12 @@ function Metrics({ getService }: MetricsProps): JSX.Element { } return ( - <> + + + - + ); } diff --git a/frontend/src/store/reducers/app.ts b/frontend/src/store/reducers/app.ts index 6fbc48049d..66c6bd6fc7 100644 --- a/frontend/src/store/reducers/app.ts +++ b/frontend/src/store/reducers/app.ts @@ -18,6 +18,7 @@ import { UPDATE_ORG_NAME, UPDATE_USER, UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + UPDATE_USER_FLAG, UPDATE_USER_IS_FETCH, UPDATE_USER_ORG_ROLE, } from 'types/actions/app'; @@ -58,6 +59,7 @@ const InitialValue: InitialValueTypes = { org: null, role: null, configs: {}, + userFlags: {}, }; const appReducer = ( @@ -153,6 +155,7 @@ const appReducer = ( ROLE, orgId, orgName, + userFlags, } = action.payload; const orgIndex = org.findIndex((e) => e.id === orgId); @@ -179,6 +182,7 @@ const appReducer = ( }, org: [...updatedOrg], role: ROLE, + userFlags, }; } @@ -219,6 +223,14 @@ const appReducer = ( }; } + case UPDATE_USER_FLAG: { + console.log('herei n update user flag'); + return { + ...state, + userFlags: { ...state.userFlags, ...action.payload.flags }, + }; + } + default: return state; } diff --git a/frontend/src/types/actions/app.ts b/frontend/src/types/actions/app.ts index a2a4b90f39..031b5ed172 100644 --- a/frontend/src/types/actions/app.ts +++ b/frontend/src/types/actions/app.ts @@ -3,6 +3,7 @@ import { Organization, PayloadProps as OrgPayload, } from 'types/api/user/getOrganization'; +import { UserFlags } from 'types/api/user/setFlags'; import AppReducer, { User } from 'types/reducer/app'; import { ROLES } from 'types/roles'; @@ -24,6 +25,7 @@ export const UPDATE_ORG_NAME = 'UPDATE_ORG_NAME'; export const UPDATE_ORG = 'UPDATE_ORG'; export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS'; export const UPDATE_CONFIGS = 'UPDATE_CONFIGS'; +export const UPDATE_USER_FLAG = 'UPDATE_USER_FLAG'; export interface SwitchDarkMode { type: typeof SWITCH_DARK_MODE; @@ -92,6 +94,7 @@ export interface UpdateUser { orgName: Organization['name']; ROLE: ROLES; orgId: Organization['id']; + userFlags: UserFlags; }; } @@ -110,6 +113,13 @@ export interface UpdateOrgName { }; } +export interface UpdateUserFlag { + type: typeof UPDATE_USER_FLAG; + payload: { + flags: UserFlags | null; + }; +} + export interface UpdateOrg { type: typeof UPDATE_ORG; payload: { @@ -137,4 +147,5 @@ export type AppAction = | UpdateOrgName | UpdateOrg | UpdateFeatureFlags - | UpdateConfigs; + | UpdateConfigs + | UpdateUserFlag; diff --git a/frontend/src/types/api/user/getUser.ts b/frontend/src/types/api/user/getUser.ts index e066ec3103..0c7ba00cfb 100644 --- a/frontend/src/types/api/user/getUser.ts +++ b/frontend/src/types/api/user/getUser.ts @@ -1,3 +1,4 @@ +import { UserFlags } from 'types/api/user/setFlags'; import { User } from 'types/reducer/app'; import { ROLES } from 'types/roles'; @@ -15,4 +16,5 @@ export interface PayloadProps { profilePictureURL: string; organization: string; role: ROLES; + flags: UserFlags; } diff --git a/frontend/src/types/api/user/setFlags.ts b/frontend/src/types/api/user/setFlags.ts new file mode 100644 index 0000000000..9dc2bc09aa --- /dev/null +++ b/frontend/src/types/api/user/setFlags.ts @@ -0,0 +1,12 @@ +import { User } from 'types/reducer/app'; + +export interface UserFlags { + ReleaseNote0120Hide?: string; +} + +export type PayloadProps = UserFlags; + +export interface Props { + userId: User['userId']; + flags: UserFlags; +} diff --git a/frontend/src/types/reducer/app.ts b/frontend/src/types/reducer/app.ts index d95fd3a77b..05c34ff4ce 100644 --- a/frontend/src/types/reducer/app.ts +++ b/frontend/src/types/reducer/app.ts @@ -2,6 +2,7 @@ import { PayloadProps as ConfigPayload } from 'types/api/dynamicConfigs/getDynam import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags'; import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization'; import { PayloadProps as UserPayload } from 'types/api/user/getUser'; +import { UserFlags } from 'types/api/user/setFlags'; import { ROLES } from 'types/roles'; export interface User { @@ -28,4 +29,5 @@ export default interface AppReducer { org: OrgPayload | null; featureFlags: null | FeatureFlagPayload; configs: ConfigPayload; + userFlags: null | UserFlags; } diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 60dbbf6987..1dc342390e 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -392,6 +392,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router) { router.HandleFunc("/api/v1/user/{id}", SelfAccess(aH.editUser)).Methods(http.MethodPut) router.HandleFunc("/api/v1/user/{id}", AdminAccess(aH.deleteUser)).Methods(http.MethodDelete) + router.HandleFunc("/api/v1/user/{id}/flags", SelfAccess(aH.patchUserFlag)).Methods(http.MethodPatch) + router.HandleFunc("/api/v1/rbac/role/{id}", SelfAccess(aH.getRole)).Methods(http.MethodGet) router.HandleFunc("/api/v1/rbac/role/{id}", AdminAccess(aH.editRole)).Methods(http.MethodPut) @@ -1854,6 +1856,37 @@ func (aH *APIHandler) deleteUser(w http.ResponseWriter, r *http.Request) { aH.WriteJSON(w, r, map[string]string{"data": "user deleted successfully"}) } +// addUserFlag patches a user flags with the changes +func (aH *APIHandler) patchUserFlag(w http.ResponseWriter, r *http.Request) { + // read user id from path var + userId := mux.Vars(r)["id"] + + // read input into user flag + defer r.Body.Close() + b, err := ioutil.ReadAll(r.Body) + if err != nil { + zap.S().Errorf("failed read user flags from http request for userId ", userId, "with error: ", err) + RespondError(w, model.BadRequestStr("received user flags in invalid format"), nil) + return + } + flags := make(map[string]string, 0) + + err = json.Unmarshal(b, &flags) + if err != nil { + zap.S().Errorf("failed parsing user flags for userId ", userId, "with error: ", err) + RespondError(w, model.BadRequestStr("received user flags in invalid format"), nil) + return + } + + newflags, apiError := dao.DB().UpdateUserFlags(r.Context(), userId, flags) + if !apiError.IsNil() { + RespondError(w, apiError, nil) + return + } + + aH.Respond(w, newflags) +} + func (aH *APIHandler) getRole(w http.ResponseWriter, r *http.Request) { id := mux.Vars(r)["id"] diff --git a/pkg/query-service/dao/interface.go b/pkg/query-service/dao/interface.go index a9a41d755c..974b313bf0 100644 --- a/pkg/query-service/dao/interface.go +++ b/pkg/query-service/dao/interface.go @@ -41,6 +41,8 @@ type Mutations interface { EditUser(ctx context.Context, update *model.User) (*model.User, *model.ApiError) DeleteUser(ctx context.Context, id string) *model.ApiError + UpdateUserFlags(ctx context.Context, userId string, flags map[string]string) (model.UserFlag, *model.ApiError) + CreateGroup(ctx context.Context, group *model.Group) (*model.Group, *model.ApiError) DeleteGroup(ctx context.Context, id string) *model.ApiError diff --git a/pkg/query-service/dao/sqlite/connection.go b/pkg/query-service/dao/sqlite/connection.go index 0af6cdeb8b..6cbe164951 100644 --- a/pkg/query-service/dao/sqlite/connection.go +++ b/pkg/query-service/dao/sqlite/connection.go @@ -68,6 +68,11 @@ func InitDB(dataSourceName string) (*ModelDaoSqlite, error) { token TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ); + CREATE TABLE IF NOT EXISTS user_flags ( + user_id TEXT PRIMARY KEY, + flags TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) + ); ` _, err = db.Exec(table_schema) diff --git a/pkg/query-service/dao/sqlite/rbac.go b/pkg/query-service/dao/sqlite/rbac.go index 81b2a79cf4..9bb8a51ebb 100644 --- a/pkg/query-service/dao/sqlite/rbac.go +++ b/pkg/query-service/dao/sqlite/rbac.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "encoding/json" "fmt" "time" @@ -271,11 +272,14 @@ func (mds *ModelDaoSqlite) GetUser(ctx context.Context, u.org_id, u.group_id, g.name as role, - o.name as organization + o.name as organization, + COALESCE((select uf.flags + from user_flags uf + where u.id = uf.user_id), '') as flags from users u, groups g, organizations o where g.id=u.group_id and - o.id = u.org_id and + o.id = u.org_id and u.id=?;` if err := mds.db.Select(&users, query, id); err != nil { @@ -291,6 +295,7 @@ func (mds *ModelDaoSqlite) GetUser(ctx context.Context, if len(users) == 0 { return nil, nil } + return &users[0], nil } @@ -531,3 +536,53 @@ func (mds *ModelDaoSqlite) GetResetPasswordEntry(ctx context.Context, } return &entries[0], nil } + +// CreateUserFlags inserts user specific flags +func (mds *ModelDaoSqlite) UpdateUserFlags(ctx context.Context, userId string, flags map[string]string) (model.UserFlag, *model.ApiError) { + + if len(flags) == 0 { + // nothing to do as flags are empty. In this method, we only append the flags + // but not set them to empty + return flags, nil + } + + // fetch existing flags + userPayload, apiError := mds.GetUser(ctx, userId) + if apiError != nil { + return nil, apiError + } + + if userPayload.Flags != nil { + for k, v := range userPayload.Flags { + if _, ok := flags[k]; !ok { + // insert only missing keys as we want to retain the + // flags in the db that are not part of this request + flags[k] = v + } + } + } + + // append existing flags with new ones + + // write the updated flags + flagsBytes, err := json.Marshal(flags) + if err != nil { + return nil, model.InternalError(err) + } + + if len(userPayload.Flags) == 0 { + q := `INSERT INTO user_flags (user_id, flags) VALUES (?, ?);` + + if _, err := mds.db.ExecContext(ctx, q, userId, string(flagsBytes)); err != nil { + return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} + } + } else { + q := `UPDATE user_flags SET flags = ? WHERE user_id = ?;` + + if _, err := mds.db.ExecContext(ctx, q, userId, string(flagsBytes)); err != nil { + return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} + } + } + + return flags, nil +} diff --git a/pkg/query-service/model/db.go b/pkg/query-service/model/db.go index 222f2bcc11..e043dd2ddd 100644 --- a/pkg/query-service/model/db.go +++ b/pkg/query-service/model/db.go @@ -1,5 +1,11 @@ package model +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + type Organization struct { Id string `json:"id" db:"id"` Name string `json:"name" db:"name"` @@ -30,10 +36,42 @@ type User struct { GroupId string `json:"groupId,omitempty" db:"group_id"` } +type UserFlag map[string]string + +func (uf UserFlag) Value() (driver.Value, error) { + f := make(map[string]string, 0) + for k, v := range uf { + f[k] = v + } + return json.Marshal(f) +} + +func (uf *UserFlag) Scan(value interface{}) error { + fmt.Println(" value:", value) + if value == "" { + return nil + } + + b, ok := value.(string) + if !ok { + return fmt.Errorf("type assertion to []byte failed while scanning user flag") + } + f := make(map[string]string, 0) + if err := json.Unmarshal([]byte(b), &f); err != nil { + return err + } + *uf = make(UserFlag, len(f)) + for k, v := range f { + (*uf)[k] = v + } + return nil +} + type UserPayload struct { User - Role string `json:"role"` - Organization string `json:"organization"` + Role string `json:"role"` + Organization string `json:"organization"` + Flags UserFlag `json:"flags"` } type Group struct { diff --git a/pkg/query-service/model/response.go b/pkg/query-service/model/response.go index 27b466b9ea..7ae79be456 100644 --- a/pkg/query-service/model/response.go +++ b/pkg/query-service/model/response.go @@ -72,6 +72,14 @@ func BadRequest(err error) *ApiError { } } +// BadRequestStr returns a ApiError object of bad request +func BadRequestStr(s string) *ApiError { + return &ApiError{ + Typ: ErrorBadData, + Err: fmt.Errorf(s), + } +} + // InternalError returns a ApiError object of internal type func InternalError(err error) *ApiError { return &ApiError{