JSON Views

Overview

JSON Views are responsible for transforming Maestro output data into structured JSON responses for API endpoints. They handle data serialization, format consistency, and provide clean interfaces between business logic and API consumers.

Purpose

JSON Views serve several key functions: - Data Serialization - Transform entities and data into JSON format - Response Structure - Provide consistent API response formats - Data Filtering - Control which fields are included in responses - Error Formatting - Structure error responses consistently - API Versioning Support - Handle different API response formats

Structure

Each JSON view typically follows this pattern:

type ListPlantsJsonView struct {
    PlantSerializer *serializers.PlantSerializer
    Kit             *kit.Kit
}

func (this *ListPlantsJsonView) H(out *maes.ListPlantsMaeOut) (map[string]interface{}, error) {
    response := make(map[string]interface{})

    // Serialize main data
    plants, err := this.PlantSerializer.SerializeMany(out.Plants, "list")
    if err != nil {
        return nil, fmt.Errorf("failed to serialize plants: %w", err)
    }

    response["plants"] = plants
    response["pagination"] = this.serializePagination(out.Pagination)

    return response, nil
}

Core Methods

Main Serialization Method

func (this *ShowPlantJsonView) H(out *maes.ShowPlantMaeOut) (map[string]interface{}, error) {
    plant, err := this.PlantSerializer.SerializeOne(out.Plant, "show")
    if err != nil {
        return nil, fmt.Errorf("failed to serialize plant: %w", err)
    }

    response := map[string]interface{}{
        "plant": plant,
    }

    if out.RelatedData != nil {
        response["related"] = this.serializeRelatedData(out.RelatedData)
    }

    return response, nil
}

Collection Serialization

func (this *ListPlantsJsonView) serializePlants(plants []*mdl.Plant) ([]map[string]interface{}, error) {
    var serialized []map[string]interface{}

    for _, plant := range plants {
        plantData, err := this.PlantSerializer.SerializeOne(plant, "list")
        if err != nil {
            return nil, fmt.Errorf("failed to serialize plant %s: %w", plant.Id, err)
        }
        serialized = append(serialized, plantData)
    }

    return serialized, nil
}

Metadata Serialization

func (this *ListPlantsJsonView) serializePagination(pagination *common.Pagination) map[string]interface{} {
    if pagination == nil {
        return nil
    }

    return map[string]interface{}{
        "current_page": pagination.CurrentPage,
        "per_page":     pagination.PerPage,
        "total":        pagination.Total,
        "total_pages":  pagination.TotalPages,
        "has_next":     pagination.HasNext,
        "has_prev":     pagination.HasPrev,
    }
}

Response Patterns

Success Responses

// Single entity response
func (this *ShowPlantJsonView) H(out *maes.ShowPlantMaeOut) (map[string]interface{}, error) {
    plant, err := this.PlantSerializer.SerializeOne(out.Plant, "show")
    if err != nil {
        return nil, err
    }

    return map[string]interface{}{
        "success": true,
        "data":    plant,
    }, nil
}

// Collection response
func (this *ListPlantsJsonView) H(out *maes.ListPlantsMaeOut) (map[string]interface{}, error) {
    plants, err := this.PlantSerializer.SerializeMany(out.Plants, "list")
    if err != nil {
        return nil, err
    }

    return map[string]interface{}{
        "success":    true,
        "data":       plants,
        "pagination": this.serializePagination(out.Pagination),
    }, nil
}

Error Responses

func (this *JsonViewHelper) ErrorResponse(err error, code string) map[string]interface{} {
    response := map[string]interface{}{
        "success": false,
        "error": map[string]interface{}{
            "message": err.Error(),
            "code":    code,
        },
    }

    // Add validation errors if available
    if validationErr, ok := err.(*ValidationError); ok {
        response["error"].(map[string]interface{})["validation"] = validationErr.Fields
    }

    return response
}

Nested Data Serialization

func (this *ShowGardenJsonView) H(out *maes.ShowGardenMaeOut) (map[string]interface{}, error) {
    garden, err := this.GardenSerializer.SerializeOne(out.Garden, "show")
    if err != nil {
        return nil, err
    }

    response := map[string]interface{}{
        "garden": garden,
    }

    // Include related plants if hydrated
    if len(out.Garden.Plants) > 0 {
        plants, err := this.PlantSerializer.SerializeMany(out.Garden.Plants, "embedded")
        if err != nil {
            return nil, fmt.Errorf("failed to serialize plants: %w", err)
        }
        response["garden"].(map[string]interface{})["plants"] = plants
    }

    // Include user information if available
    if out.Garden.User != nil {
        user, err := this.UserSerializer.SerializeOne(out.Garden.User, "basic")
        if err != nil {
            return nil, fmt.Errorf("failed to serialize user: %w", err)
        }
        response["garden"].(map[string]interface{})["user"] = user
    }

    return response, nil
}

Serialization Contexts

Context-Based Serialization

func (this *PlantJsonView) serializePlant(plant *mdl.Plant, context string) (map[string]interface{}, error) {
    switch context {
    case "list":
        return this.PlantSerializer.SerializeOne(plant, "list")
    case "show":
        return this.PlantSerializer.SerializeOne(plant, "show")
    case "embedded":
        return this.PlantSerializer.SerializeOne(plant, "embedded")
    default:
        return this.PlantSerializer.SerializeOne(plant, "basic")
    }
}

Field Selection

func (this *PlantJsonView) serializeWithFields(plant *mdl.Plant, fields []string) (map[string]interface{}, error) {
    if len(fields) == 0 {
        return this.PlantSerializer.SerializeOne(plant, "show")
    }

    // Create custom serialization based on requested fields
    result := make(map[string]interface{})

    for _, field := range fields {
        switch field {
        case "id":
            result["id"] = plant.Id
        case "name":
            result["name"] = plant.Name
        case "species":
            result["species"] = plant.Species
        case "size":
            result["size"] = plant.Size
        case "created_at":
            result["created_at"] = plant.CreatedAt.Format(time.RFC3339)
        }
    }

    return result, nil
}

Integration with Controllers

JSON Views are typically used in API controller methods:

func (gc *GardenController) ApiList(c *gin.Context) {
    input := &maes.ListGardensMaeIn{}

    // Bind query parameters
    if err := gc.ListGardensBinder.Bind(c.Request.Context(), input, c); err != nil {
        c.JSON(400, gc.ErrorJsonView.ValidationError(err))
        return
    }

    // Execute business logic
    output, err := gc.ListGardensMae.Execute(c.Request.Context(), input)
    if err != nil {
        c.JSON(500, gc.ErrorJsonView.ServerError(err))
        return
    }

    // Serialize response
    response, err := gc.ListGardensJsonView.H(output)
    if err != nil {
        c.JSON(500, gc.ErrorJsonView.SerializationError(err))
        return
    }

    c.JSON(200, response)
}

Error Handling

Serialization Error Recovery

func (this *PlantJsonView) H(out *maes.ShowPlantMaeOut) (map[string]interface{}, error) {
    plant, err := this.PlantSerializer.SerializeOne(out.Plant, "show")
    if err != nil {
        // Fallback to basic serialization
        plant = map[string]interface{}{
            "id":   out.Plant.Id,
            "name": out.Plant.Name,
            "error": "Serialization incomplete",
        }
    }

    return map[string]interface{}{
        "plant": plant,
    }, nil
}

Partial Failure Handling

func (this *ListPlantsJsonView) H(out *maes.ListPlantsMaeOut) (map[string]interface{}, error) {
    var plants []map[string]interface{}
    var errors []string

    for _, plant := range out.Plants {
        plantData, err := this.PlantSerializer.SerializeOne(plant, "list")
        if err != nil {
            errors = append(errors, fmt.Sprintf("Failed to serialize plant %s: %v", plant.Id, err))
            // Include minimal data
            plantData = map[string]interface{}{
                "id":    plant.Id,
                "name":  plant.Name,
                "error": "Serialization failed",
            }
        }
        plants = append(plants, plantData)
    }

    response := map[string]interface{}{
        "plants": plants,
    }

    if len(errors) > 0 {
        response["warnings"] = errors
    }

    return response, nil
}

API Versioning

Version-Specific Responses

func (this *PlantJsonView) H(out *maes.ShowPlantMaeOut) (map[string]interface{}, error) {
    version := this.getAPIVersion(out.Context)

    switch version {
    case "v1":
        return this.serializeV1(out.Plant)
    case "v2":
        return this.serializeV2(out.Plant)
    default:
        return this.serializeLatest(out.Plant)
    }
}

func (this *PlantJsonView) serializeV1(plant *mdl.Plant) (map[string]interface{}, error) {
    return map[string]interface{}{
        "id":      plant.Id,
        "name":    plant.Name,
        "species": plant.Species,
    }, nil
}

func (this *PlantJsonView) serializeV2(plant *mdl.Plant) (map[string]interface{}, error) {
    return map[string]interface{}{
        "id":         plant.Id,
        "name":       plant.Name,
        "species":    plant.Species,
        "size":       plant.Size,
        "perennial":  plant.Perennial,
        "created_at": plant.CreatedAt.Format(time.RFC3339),
    }, nil
}

Performance Optimization

Lazy Loading

func (this *ListPlantsJsonView) H(out *maes.ListPlantsMaeOut) (map[string]interface{}, error) {
    // Serialize basic plant data first
    plants := make([]map[string]interface{}, len(out.Plants))

    for i, plant := range out.Plants {
        plants[i] = map[string]interface{}{
            "id":   plant.Id,
            "name": plant.Name,
        }
    }

    response := map[string]interface{}{
        "plants": plants,
    }

    // Add detailed data if requested
    if out.Context.IncludeDetails {
        for i, plant := range out.Plants {
            detailed, err := this.PlantSerializer.SerializeOne(plant, "detailed")
            if err == nil {
                plants[i] = detailed
            }
        }
    }

    return response, nil
}

Testing JSON Views

func TestShowPlantJsonView_H(t *testing.T) {
    view := setupShowPlantJsonView()

    output := &maes.ShowPlantMaeOut{
        Plant: &mdl.Plant{
            Id:      "plant123",
            Name:    "Tomato",
            Species: "Solanum lycopersicum",
        },
    }

    response, err := view.H(output)

    assert.NoError(t, err)
    assert.Equal(t, "plant123", response["plant"].(map[string]interface{})["id"])
    assert.Equal(t, "Tomato", response["plant"].(map[string]interface{})["name"])
}

func TestListPlantsJsonView_ErrorHandling(t *testing.T) {
    view := setupListPlantsJsonView()

    output := &maes.ListPlantsMaeOut{
        Plants: []*mdl.Plant{
            {Id: "plant1", Name: "Valid Plant"},
            {Id: "plant2", Name: ""}, // This might cause serialization issues
        },
    }

    response, err := view.H(output)

    assert.NoError(t, err)
    assert.Len(t, response["plants"], 2)
}

Best Practices

Consistency

  • Use consistent response structures across all endpoints
  • Maintain consistent field naming conventions
  • Apply consistent error response formats

Performance

  • Avoid N+1 queries in serialization
  • Use appropriate serialization contexts
  • Implement caching for expensive serializations

Maintainability

  • Keep views focused on serialization logic
  • Use serializers for complex data transformation
  • Handle errors gracefully with fallbacks

API Design

  • Follow RESTful response conventions
  • Support field selection when appropriate
  • Provide clear error messages and codes

JSON Views provide clean, consistent API responses while maintaining flexibility and performance for various API consumption patterns.