feat: initial commit

This commit is contained in:
2025-03-31 16:17:35 +01:00
commit 6719f8a61a
91 changed files with 7119 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
package cache
import (
"context"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/stretchr/testify/mock"
)
func NewMockCache() Storage {
return Storage{
Users: &MockUserStore{},
}
}
type MockUserStore struct {
mock.Mock
}
func (m *MockUserStore) Get(ctx context.Context, id int64) (*store.User, error) {
args := m.Called(id)
return nil, args.Error(1)
}
func (m *MockUserStore) Set(ctx context.Context, user *store.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
+11
View File
@@ -0,0 +1,11 @@
package cache
import "github.com/go-redis/redis/v8"
func NewRedisClient(addr, pw string, db int) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: addr,
Password: pw,
DB: db,
})
}
+22
View File
@@ -0,0 +1,22 @@
package cache
import (
"context"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-redis/redis/v8"
)
type Storage struct {
Users interface {
Get(context.Context, int64) (*store.User, error)
Set(context.Context, *store.User) error
Delete(context.Context, int64) error
}
}
func NewRedisStorage(rdb *redis.Client) Storage {
return Storage{
Users: &UserStore{rdb: rdb},
}
}
+60
View File
@@ -0,0 +1,60 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-redis/redis/v8"
)
type UserStore struct {
rdb *redis.Client
}
const UserExpTime = time.Hour * 24
func (s *UserStore) Get(ctx context.Context, id int64) (*store.User, error) {
cacheKey := fmt.Sprintf("user-%d", id)
data, err := s.rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return nil, nil
} else if err != nil {
return nil, err
}
var user store.User
if data != "" {
err := json.Unmarshal([]byte(data), &user)
if err != nil {
return nil, err
}
}
return &user, nil
}
func (s *UserStore) Set(ctx context.Context, user *store.User) error {
if user.ID == 0 {
return fmt.Errorf("user ID is required")
}
cacheKey := fmt.Sprintf("user-%d", user.ID)
json, err := json.Marshal(user)
if err != nil {
return err
}
return s.rdb.SetEX(ctx, cacheKey, json, UserExpTime).Err()
}
func (s *UserStore) Delete(ctx context.Context, id int64) error {
cacheKey := fmt.Sprintf("user-%d", id)
return s.rdb.Del(ctx, cacheKey).Err()
}
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"context"
"database/sql"
)
type Comment struct {
ID int64 `json:"id"`
PostID int64 `json:"post_id"`
UserID int64 `json:"user_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
User User `json:"user"`
}
type CommentStore struct {
db *sql.DB
}
func (s *CommentStore) Create(ctx context.Context, c *Comment) error {
query := `INSERT INTO comments (post_id, user_id, content) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at;`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(ctx, query, c.PostID, c.UserID, c.Content).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return err
}
return nil
}
func (s *CommentStore) GetByPostID(ctx context.Context, postID int64) ([]Comment, error) {
query := `SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, users.username, users.id FROM comments c
JOIN users on c.user_id = users.id
WHERE c.post_id = $1
ORDER BY c.created_at DESC;
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
comments := []Comment{}
for rows.Next() {
var comment Comment
comment.User = User{}
err := rows.Scan(
&comment.ID,
&comment.PostID,
&comment.UserID,
&comment.Content,
&comment.CreatedAt,
&comment.User.Username,
&comment.User.ID,
)
if err != nil {
return nil, err
}
comments = append(comments, comment)
}
return comments, nil
}
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"context"
"database/sql"
"github.com/lib/pq"
)
type Follower struct {
ID int64 `json:"id"`
FolloweeID int64 `json:"followee_id"`
CreatedAt string `json:"created_at"`
}
type FollowerStore struct {
db *sql.DB
}
func (s *FollowerStore) Follow(ctx context.Context, followeeID, followerID int64) error {
query := `INSERT INTO followers (user_id, follower_id) VALUES ($1, $2)`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return ErrConflict
}
}
return nil
}
func (s *FollowerStore) Unfollow(ctx context.Context, followeeID, followerID int64) error {
query := `DELETE FROM followers WHERE user_id = $1 AND follower_id = $2`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return ErrNotFound
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package store
import (
"context"
"database/sql"
"time"
"github.com/stretchr/testify/mock"
)
func NewMockStorage() Storage {
return Storage{
Users: &MockUserStore{},
}
}
type MockUserStore struct {
mock.Mock
}
func (m *MockUserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
args := m.Called(tx, user)
return args.Error(0)
}
func (m *MockUserStore) Activate(ctx context.Context, token string) error {
args := m.Called(token)
return args.Error(0)
}
func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) {
args := m.Called(id)
return nil, args.Error(1)
}
func (m *MockUserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
args := m.Called(email)
return nil, args.Error(1)
}
func (m *MockUserStore) Update(ctx context.Context, user *User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token string, expiresAt time.Duration) error {
args := m.Called(user, token, expiresAt)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
func (m *MockUserStore) DeleteUser(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"net/http"
"strconv"
"strings"
"time"
)
type PaginatedFeedQuery struct {
Limit int `json:"limit" validate:"gte=1,lte=20"`
Offset int `json:"offset" validate:"gte=0"`
Sort string `json:"sort" validate:"oneof=asc desc"`
Tags []string `json:"tags" validate:"max=5"`
Search string `json:"search" validate:"max=100"`
Since string `json:"since"`
Until string `json:"until"`
}
func (fq PaginatedFeedQuery) Parse(r *http.Request) (PaginatedFeedQuery, error) {
qs := r.URL.Query()
limit := qs.Get("limit")
if limit != "" {
l, err := strconv.Atoi(limit)
if err != nil {
return fq, err
}
fq.Limit = l
}
offset := qs.Get("offset")
if offset != "" {
o, err := strconv.Atoi(offset)
if err != nil {
return PaginatedFeedQuery{}, err
}
fq.Offset = o
}
sort := qs.Get("sort")
if sort != "" {
fq.Sort = sort
}
tags := qs.Get("tags")
if tags != "" {
fq.Tags = strings.Split(tags, ",")
}
search := qs.Get("search")
if search != "" {
fq.Search = search
}
since := qs.Get("since")
if since != "" {
fq.Since = parseTime(since)
}
until := qs.Get("until")
if until != "" {
fq.Until = parseTime(until)
}
return fq, nil
}
func parseTime(s string) string {
t, err := time.Parse(time.DateTime, s)
if err != nil {
return ""
}
return t.Format(time.DateTime)
}
+205
View File
@@ -0,0 +1,205 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/lib/pq"
)
type Post struct {
ID int64 `json:"id"`
Content string `json:"content"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Version int `json:"version"`
Comments []Comment `json:"comments"`
User User `json:"user"`
}
type PostWithMetadata struct {
Post
CommentCount int `json:"comment_count"`
}
type PostStore struct {
db *sql.DB
}
func (s *PostStore) Create(ctx context.Context, post *Post) error {
query := `
INSERT INTO posts (content, title, user_id, tags)
VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.UserID,
pq.Array(post.Tags),
).Scan(&post.ID, &post.CreatedAt, &post.UpdatedAt)
if err != nil {
return err
}
return nil
}
func (s *PostStore) GetByID(ctx context.Context, id int64) (*Post, error) {
query := `
SELECT id, content, title, user_id, tags, created_at, updated_at, version
FROM posts
WHERE id = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
var post Post
err := s.db.QueryRowContext(ctx, query, id).Scan(
&post.ID,
&post.Content,
&post.Title,
&post.UserID,
pq.Array(&post.Tags),
&post.CreatedAt,
&post.UpdatedAt,
&post.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
default:
return nil, err
}
}
return &post, nil
}
func (s *PostStore) GetUserFeed(ctx context.Context, userID int64, fq PaginatedFeedQuery) ([]PostWithMetadata, error) {
query := `
SELECT
p.id, p.user_id, p.title, p.content, p.created_at, p.updated_at, p.version, p.tags,
u.username,
COUNT(c.id) AS comments_count
FROM posts p
LEFT JOIN comments c on c.post_id = p.id
LEFT JOIN users u ON p.user_id = u.id
JOIN followers f ON f.follower_id = p.user_id OR p.user_id = $1
WHERE
f.user_id = $1 AND
(p.title ILIKE '%' || $4 || '%' OR p.content ILIKE '%' || $4 || '%') AND
(p.tags @> $5 OR $5 = '{}')
GROUP BY p.id, u.username
ORDER BY p.created_at ` + fq.Sort + `
LIMIT $2 OFFSET $3
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, query, userID, fq.Limit, fq.Offset, fq.Search, pq.Array(fq.Tags))
if err != nil {
return nil, err
}
defer rows.Close()
var feed []PostWithMetadata
for rows.Next() {
var p PostWithMetadata
err := rows.Scan(
&p.ID,
&p.UserID,
&p.Title,
&p.Content,
&p.CreatedAt,
&p.UpdatedAt,
&p.Version,
pq.Array(&p.Tags),
&p.User.Username,
&p.CommentCount,
)
if err != nil {
return nil, err
}
fmt.Println(p)
feed = append(feed, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return feed, nil
}
func (s *PostStore) Update(ctx context.Context, post *Post) error {
query := `
UPDATE posts
SET content = $1, title = $2, updated_at = NOW(), version = version + 1
WHERE id = $3 AND version = $4
RETURNING version
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.ID,
post.Version,
).Scan(&post.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
default:
return err
}
}
return nil
}
func (s *PostStore) Delete(ctx context.Context, id int64) error {
query := `
DELETE FROM posts
WHERE id = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
res, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return ErrNotFound
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package store
import (
"context"
"database/sql"
)
type Role struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int64 `json:"level"`
Description string `json:"description"`
}
type RoleStore struct {
db *sql.DB
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
query := `
SELECT id, name, level, description
FROM roles
WHERE name = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan(
&role.ID,
&role.Name,
&role.Level,
&role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return role, nil
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
var (
QueryTimeout = 5 * time.Second
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrDuplicateEmail = errors.New("email already exists")
ErrDuplicateUsername = errors.New("username already exists")
ErrPasswordNotSet = errors.New("password is empty")
)
type Storage struct {
Posts interface {
Create(context.Context, *Post) error
GetByID(context.Context, int64) (*Post, error)
GetUserFeed(context.Context, int64, PaginatedFeedQuery) ([]PostWithMetadata, error)
Update(context.Context, *Post) error
Delete(context.Context, int64) error
}
Users interface {
Create(context.Context, *sql.Tx, *User) error
Activate(context.Context, string) error
GetByID(context.Context, int64) (*User, error)
GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error
Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error
}
Comments interface {
Create(context.Context, *Comment) error
GetByPostID(context.Context, int64) ([]Comment, error)
}
Followers interface {
Follow(context.Context, int64, int64) error
Unfollow(context.Context, int64, int64) error
}
Roles interface {
GetByName(context.Context, string) (*Role, error)
}
}
func NewStorage(db *sql.DB) Storage {
return Storage{
Posts: &PostStore{db},
Users: &UserStore{db},
Comments: &CommentStore{db},
Followers: &FollowerStore{db},
Roles: &RoleStore{db},
}
}
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := f(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
+363
View File
@@ -0,0 +1,363 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
Email string `json:"email"`
Password password `json:"-"`
IsActive bool `json:"is_active"`
RoleID int64 `json:"role_id"`
Role Role `json:"role"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type password struct {
text *string `validate:"required,password"`
hash []byte
}
func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
p.text = &password
p.hash = hash
return nil
}
type UserStore struct {
db *sql.DB
}
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name
if role == "" {
role = "user"
}
err := tx.QueryRowContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
role,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
switch {
case err.Error() == `pq: duplicate key value violates unique constraint "users_username_key"`:
return ErrDuplicateUsername
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
return ErrDuplicateEmail
default:
return err
}
}
return nil
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
user.ID,
)
if err != nil {
return err
}
return nil
}
func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token string, invitationExp time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.Create(ctx, tx, user); err != nil {
return err
}
if err := s.createUserInvitation(ctx, tx, user.ID, invitationExp, token); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token)
if err != nil {
return err
}
// Update the user's status to active
user.IsActive = true
if err := s.update(ctx, tx, user); err != nil {
return err
}
// Delete the token from the invitations table
if err := s.deleteUserInvitation(ctx, tx, user.ID); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Delete(ctx context.Context, id int64) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.delete(ctx, tx, id); err != nil {
return err
}
if err := s.deleteUserInvitation(ctx, tx, id); err != nil {
return err
}
return nil
})
}
// * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
return nil
}
func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
}
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.CreatedAt,
&user.UpdatedAt,
&user.IsActive,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp))
if err != nil {
return err
}
return nil
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.IsActive,
user.ID,
)
if err != nil {
return err
}
return nil
}
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID)
if err != nil {
return err
}
return nil
}
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, id)
if err != nil {
return err
}
return nil
}