- Use FxTwitter API for full note tweet text (with syndication API fallback) - Save Twitter posts based on media content: - Videos → embed type (proxied video) - Images → image type (gallery) - Text-only → quote type - Add granular preview badges: 'X VIDEO', 'X GALLERY', 'X POST' - Preserve formatting/spacing with white-space: pre-wrap for quotes and descriptions - Rename VideoInfo to EmbedInfo for better semantic clarity
635 lines
17 KiB
Go
635 lines
17 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"lookbook/internal/data/item"
|
|
"lookbook/internal/data/media"
|
|
"lookbook/internal/data/tag"
|
|
"lookbook/internal/embed"
|
|
"lookbook/internal/opengraph"
|
|
"lookbook/internal/video"
|
|
)
|
|
|
|
// strOrNil returns nil if s is empty, otherwise a pointer to s.
|
|
func strOrNil(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// nullStr creates a sql.Null[string] from a string (empty string = invalid).
|
|
func nullStr(s string) sql.Null[string] {
|
|
if s == "" {
|
|
return sql.Null[string]{}
|
|
}
|
|
return sql.Null[string]{V: s, Valid: true}
|
|
}
|
|
|
|
// downloadAndStoreImages downloads images from URLs and stores them for an item.
|
|
// All images are stored with media_type "image". The first image (by ID order) serves as the thumbnail.
|
|
// If imageURLs is empty but imageURL is set, stores that single image.
|
|
func downloadAndStoreImages(
|
|
ctx context.Context,
|
|
db *sql.DB,
|
|
logger *slog.Logger,
|
|
itemID int64,
|
|
imageURL string,
|
|
imageURLs []string,
|
|
) {
|
|
if len(imageURLs) > 0 {
|
|
// Multi-image (e.g., Twitter with multiple photos)
|
|
for i, imgURL := range imageURLs {
|
|
imgData, contentType, err := opengraph.DownloadImage(ctx, imgURL)
|
|
if err != nil {
|
|
logger.Warn("failed to download image", "url", imgURL, "index", i, "error", err)
|
|
continue
|
|
}
|
|
_, err = media.QCreate(ctx, db, media.CreateParams{
|
|
ItemID: itemID,
|
|
MediaType: "image",
|
|
ContentType: contentType,
|
|
Data: imgData,
|
|
SourceURL: &imgURL,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("failed to store image", "index", i, "error", err)
|
|
}
|
|
}
|
|
} else if imageURL != "" {
|
|
// Single image
|
|
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
|
|
if err != nil {
|
|
logger.Warn("failed to download image", "url", imageURL, "error", err)
|
|
return
|
|
}
|
|
_, err = media.QCreate(ctx, db, media.CreateParams{
|
|
ItemID: itemID,
|
|
MediaType: "image",
|
|
ContentType: contentType,
|
|
Data: imgData,
|
|
SourceURL: &imageURL,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("failed to store image", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type urlMetadata struct {
|
|
Title string
|
|
Description string
|
|
ImageURL string
|
|
ImageURLs []string // All image URLs (for multi-image tweets)
|
|
SiteName string
|
|
IsEmbed bool
|
|
Provider string
|
|
VideoID string
|
|
EmbedHTML string
|
|
VideoURL string // Direct video URL (for Twitter)
|
|
MediaType string // "video", "images", "text" - for Twitter only
|
|
}
|
|
|
|
func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
|
|
// Check if it's a YouTube/Vimeo/Twitter embed
|
|
embedInfo, err := embed.Detect(ctx, url)
|
|
if err == nil && embedInfo != nil {
|
|
return &urlMetadata{
|
|
Title: embedInfo.Title,
|
|
Description: embedInfo.Description,
|
|
ImageURL: embedInfo.ThumbnailURL,
|
|
ImageURLs: embedInfo.ThumbnailURLs,
|
|
IsEmbed: true,
|
|
Provider: string(embedInfo.Provider),
|
|
VideoID: embedInfo.VideoID,
|
|
EmbedHTML: embedInfo.EmbedHTML,
|
|
VideoURL: embedInfo.VideoURL,
|
|
MediaType: embedInfo.MediaType,
|
|
}, nil
|
|
}
|
|
|
|
// Fetch OpenGraph metadata
|
|
meta, err := opengraph.Fetch(ctx, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &urlMetadata{
|
|
Title: meta.Title,
|
|
Description: meta.Description,
|
|
ImageURL: meta.ImageURL,
|
|
SiteName: meta.SiteName,
|
|
IsEmbed: false,
|
|
}, nil
|
|
}
|
|
|
|
type previewResponse struct {
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
ImageURL string `json:"imageUrl,omitempty"`
|
|
SiteName string `json:"siteName,omitempty"`
|
|
IsEmbed bool `json:"isEmbed"`
|
|
Provider string `json:"provider,omitempty"`
|
|
VideoID string `json:"videoId,omitempty"`
|
|
EmbedHTML string `json:"embedHtml,omitempty"`
|
|
MediaType string `json:"mediaType,omitempty"` // "video", "images", "text" - for Twitter
|
|
}
|
|
|
|
// HandlePreviewLink handles POST /api/preview - fetches metadata for a URL
|
|
func HandlePreviewLink(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
if !rc.RequireAdmin(w) {
|
|
return nil
|
|
}
|
|
|
|
var req struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
if req.URL == "" {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "url required"})
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
meta, err := fetchURLMetadata(ctx, req.URL)
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)})
|
|
}
|
|
|
|
return writeJSON(w, http.StatusOK, previewResponse{
|
|
Title: meta.Title,
|
|
Description: meta.Description,
|
|
ImageURL: meta.ImageURL,
|
|
SiteName: meta.SiteName,
|
|
IsEmbed: meta.IsEmbed,
|
|
Provider: meta.Provider,
|
|
VideoID: meta.VideoID,
|
|
EmbedHTML: meta.EmbedHTML,
|
|
MediaType: meta.MediaType,
|
|
})
|
|
}
|
|
|
|
type createFromLinkRequest struct {
|
|
URL string `json:"url"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Tags []string `json:"tags"`
|
|
// For embeds:
|
|
Provider *string `json:"provider"`
|
|
VideoID *string `json:"videoId"`
|
|
EmbedHTML *string `json:"embedHtml"`
|
|
// For downloading hero image:
|
|
ImageURL *string `json:"imageUrl"`
|
|
}
|
|
|
|
// HandleCreateFromLink handles POST /api/items/from-link
|
|
func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
if !rc.RequireAdmin(w) {
|
|
return nil
|
|
}
|
|
|
|
var req createFromLinkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
var itemType string
|
|
var embedProvider, embedVideoID, embedHTML *string
|
|
var imageURL, videoURL string
|
|
var imageURLs []string // For multi-image tweets
|
|
|
|
if req.Provider != nil && *req.Provider != "" {
|
|
// Special handling for Twitter based on media content
|
|
if *req.Provider == "twitter" {
|
|
// Fetch tweet info to determine content type
|
|
videoInfo, err := embed.Detect(ctx, req.URL)
|
|
if err == nil && videoInfo != nil {
|
|
if videoInfo.VideoURL != "" {
|
|
// Tweet has video → keep as embed (proxied)
|
|
itemType = "embed"
|
|
embedProvider = req.Provider
|
|
embedVideoID = req.VideoID
|
|
embedHTML = req.EmbedHTML
|
|
videoURL = videoInfo.VideoURL
|
|
imageURL = videoInfo.ThumbnailURL
|
|
} else if len(videoInfo.ThumbnailURLs) > 0 {
|
|
// Tweet has image(s) → save as image
|
|
itemType = "image"
|
|
imageURL = videoInfo.ThumbnailURL
|
|
imageURLs = videoInfo.ThumbnailURLs
|
|
} else {
|
|
// Text-only tweet → save as quote
|
|
itemType = "quote"
|
|
// title and description already set from request
|
|
}
|
|
} else {
|
|
// If detection fails, fall back to link
|
|
itemType = "link"
|
|
}
|
|
} else {
|
|
// YouTube, Vimeo, etc. → keep as embed
|
|
itemType = "embed"
|
|
embedProvider = req.Provider
|
|
embedVideoID = req.VideoID
|
|
embedHTML = req.EmbedHTML
|
|
// Fetch thumbnail for non-Twitter embeds
|
|
if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
|
|
imageURL = videoInfo.ThumbnailURL
|
|
videoURL = videoInfo.VideoURL
|
|
}
|
|
}
|
|
} else if req.ImageURL != nil && *req.ImageURL != "" {
|
|
// It's a link with an image
|
|
itemType = "image"
|
|
imageURL = *req.ImageURL
|
|
} else {
|
|
// Just a link (will be shown as a card)
|
|
itemType = "link"
|
|
}
|
|
|
|
// Create the item
|
|
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
|
|
Title: item.Nullable(req.Title),
|
|
Description: item.Nullable(req.Description),
|
|
LinkURL: item.Nullable(&req.URL),
|
|
ItemType: itemType,
|
|
EmbedProvider: item.Nullable(embedProvider),
|
|
EmbedVideoID: item.Nullable(embedVideoID),
|
|
EmbedHTML: item.Nullable(embedHTML),
|
|
EmbedVideoURL: nullStr(videoURL),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Download and store images
|
|
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, imageURL, imageURLs)
|
|
|
|
// Set tags
|
|
if len(req.Tags) > 0 {
|
|
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
resp, err := buildItemResponse(ctx, rc, it)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// HandleUpload handles POST /api/items/upload - multipart file upload
|
|
func HandleUpload(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
if !rc.RequireAdmin(w) {
|
|
return nil
|
|
}
|
|
|
|
// Parse multipart form (max 500MB)
|
|
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse form"})
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
|
|
}
|
|
defer file.Close()
|
|
|
|
title := r.FormValue("title")
|
|
description := r.FormValue("description")
|
|
tagsStr := r.FormValue("tags")
|
|
|
|
var tags []string
|
|
if tagsStr != "" {
|
|
tags = strings.Split(tagsStr, ",")
|
|
for i := range tags {
|
|
tags[i] = strings.TrimSpace(tags[i])
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// Read file data
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read file"})
|
|
}
|
|
|
|
contentType := header.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(data)
|
|
}
|
|
|
|
var itemType string
|
|
var originalData, thumbnailData []byte
|
|
var originalContentType string
|
|
|
|
if video.IsVideo(contentType) {
|
|
itemType = "video"
|
|
// Process video: transcode and extract thumbnail
|
|
transcoded, thumbnail, err := video.ProcessVideo(ctx, data, contentType)
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("video processing failed: %v", err)})
|
|
}
|
|
originalData = transcoded
|
|
originalContentType = "video/mp4"
|
|
thumbnailData = thumbnail
|
|
} else if video.IsImage(contentType) {
|
|
itemType = "image"
|
|
originalData = data
|
|
originalContentType = contentType
|
|
} else {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file type"})
|
|
}
|
|
|
|
// Create item
|
|
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
|
|
Title: item.Nullable(strOrNil(title)),
|
|
Description: item.Nullable(strOrNil(description)),
|
|
ItemType: itemType,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Store original media
|
|
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
|
|
ItemID: it.ID,
|
|
MediaType: "original",
|
|
ContentType: originalContentType,
|
|
Data: originalData,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Store thumbnail for videos
|
|
if len(thumbnailData) > 0 {
|
|
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
|
|
ItemID: it.ID,
|
|
MediaType: "thumbnail",
|
|
ContentType: "image/jpeg",
|
|
Data: thumbnailData,
|
|
})
|
|
if err != nil {
|
|
rc.Logger.Warn("failed to store thumbnail", "error", err)
|
|
}
|
|
}
|
|
|
|
// Set tags
|
|
if len(tags) > 0 {
|
|
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, tags); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
resp, err := buildItemResponse(ctx, rc, it)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
type createQuoteRequest struct {
|
|
Text string `json:"text"`
|
|
Source *string `json:"source"` // Optional attribution
|
|
SourceURL *string `json:"sourceUrl"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
// HandleCreateQuote handles POST /api/items/quote
|
|
func HandleCreateQuote(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
if !rc.RequireAdmin(w) {
|
|
return nil
|
|
}
|
|
|
|
var req createQuoteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
if req.Text == "" {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text required"})
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
|
|
Title: item.Nullable(req.Source),
|
|
Description: nullStr(req.Text),
|
|
LinkURL: item.Nullable(req.SourceURL),
|
|
ItemType: "quote",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set tags
|
|
if len(req.Tags) > 0 {
|
|
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
resp, err := buildItemResponse(ctx, rc, it)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// HandleReplaceMedia handles POST /api/items/{id}/media - replaces media for an item
|
|
func HandleReplaceMedia(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
if !rc.RequireAdmin(w) {
|
|
return nil
|
|
}
|
|
|
|
pubID := r.PathValue("id")
|
|
if pubID == "" {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
|
|
}
|
|
|
|
// Parse multipart form (max 500MB)
|
|
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse form"})
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
|
|
}
|
|
defer file.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if it == nil {
|
|
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
}
|
|
|
|
// Read file data
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read file"})
|
|
}
|
|
|
|
contentType := header.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(data)
|
|
}
|
|
|
|
var originalData, thumbnailData []byte
|
|
var originalContentType string
|
|
var newItemType string
|
|
|
|
if video.IsVideo(contentType) {
|
|
newItemType = "video"
|
|
transcoded, thumbnail, err := video.ProcessVideo(ctx, data, contentType)
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("video processing failed: %v", err)})
|
|
}
|
|
originalData = transcoded
|
|
originalContentType = "video/mp4"
|
|
thumbnailData = thumbnail
|
|
} else if video.IsImage(contentType) {
|
|
newItemType = "image"
|
|
originalData = data
|
|
originalContentType = contentType
|
|
} else {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file type"})
|
|
}
|
|
|
|
// Delete existing media
|
|
media.QDeleteByItemID(ctx, rc.DB, it.ID)
|
|
|
|
// Store new original media
|
|
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
|
|
ItemID: it.ID,
|
|
MediaType: "original",
|
|
ContentType: originalContentType,
|
|
Data: originalData,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Store thumbnail for videos
|
|
if len(thumbnailData) > 0 {
|
|
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
|
|
ItemID: it.ID,
|
|
MediaType: "thumbnail",
|
|
ContentType: "image/jpeg",
|
|
Data: thumbnailData,
|
|
})
|
|
if err != nil {
|
|
rc.Logger.Warn("failed to store thumbnail", "error", err)
|
|
}
|
|
}
|
|
|
|
// Update item type if it changed (e.g., embed -> image)
|
|
if it.ItemType != newItemType && (it.ItemType == "embed" || it.ItemType == "link") {
|
|
item.QUpdateType(ctx, rc.DB, it.ID, newItemType)
|
|
}
|
|
|
|
// Refetch and return updated item
|
|
it, err = item.QFindByID(ctx, rc.DB, it.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := buildItemResponse(ctx, rc, *it)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleRefreshMetadata handles POST /api/items/{id}/refresh
|
|
// Re-fetches metadata and thumbnail for an item with a link URL
|
|
func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
if !rc.RequireAdmin(w) {
|
|
return nil
|
|
}
|
|
|
|
pubID := r.PathValue("id")
|
|
if pubID == "" {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if it == nil {
|
|
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
}
|
|
|
|
if !it.LinkURL.Valid || it.LinkURL.V == "" {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "item has no link URL"})
|
|
}
|
|
|
|
meta, err := fetchURLMetadata(ctx, it.LinkURL.V)
|
|
if err != nil {
|
|
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)})
|
|
}
|
|
|
|
// Update title, description, and video URL
|
|
item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{
|
|
Title: nullStr(meta.Title),
|
|
Description: nullStr(meta.Description),
|
|
LinkURL: it.LinkURL,
|
|
})
|
|
if meta.VideoURL != "" {
|
|
item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL)
|
|
}
|
|
|
|
// Download and replace images
|
|
if len(meta.ImageURLs) > 0 || meta.ImageURL != "" {
|
|
// Delete existing media (thumbnails and gallery)
|
|
media.QDeleteByItemID(ctx, rc.DB, it.ID)
|
|
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, meta.ImageURL, meta.ImageURLs)
|
|
}
|
|
|
|
// Refetch and return updated item
|
|
it, err = item.QFindByID(ctx, rc.DB, it.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := buildItemResponse(ctx, rc, *it)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeJSON(w, http.StatusOK, resp)
|
|
}
|