github.com/kyma-incubator/compass/components/director@v0.0.0-20230623144113-d764f56ff805/internal/domain/application/repository.go (about) 1 package application 2 3 import ( 4 "context" 5 "fmt" 6 "time" 7 8 "github.com/kyma-incubator/compass/components/director/pkg/resource" 9 10 "github.com/kyma-incubator/compass/components/director/pkg/graphql" 11 "github.com/kyma-incubator/compass/components/director/pkg/log" 12 "github.com/kyma-incubator/compass/components/director/pkg/operation" 13 14 "github.com/google/uuid" 15 "github.com/kyma-incubator/compass/components/director/internal/domain/label" 16 "github.com/kyma-incubator/compass/components/director/internal/labelfilter" 17 "github.com/kyma-incubator/compass/components/director/internal/model" 18 "github.com/kyma-incubator/compass/components/director/internal/repo" 19 "github.com/kyma-incubator/compass/components/director/pkg/apperrors" 20 "github.com/pkg/errors" 21 ) 22 23 const ( 24 applicationTable string = `public.applications` 25 // listeningApplicationsView provides a structured view of applications that have a Webhook or their ApplicationTemplate has a Webhook 26 listeningApplicationsView = `listening_applications` 27 ) 28 29 var ( 30 applicationColumns = []string{"id", "app_template_id", "system_number", "local_tenant_id", "name", "description", "status_condition", "status_timestamp", "system_status", "healthcheck_url", "integration_system_id", "provider_name", "base_url", "application_namespace", "labels", "ready", "created_at", "updated_at", "deleted_at", "error", "correlation_ids", "tags", "documentation_labels", "tenant_business_type_id"} 31 updatableColumns = []string{"name", "description", "status_condition", "status_timestamp", "system_status", "healthcheck_url", "integration_system_id", "provider_name", "base_url", "application_namespace", "labels", "ready", "created_at", "updated_at", "deleted_at", "error", "correlation_ids", "tags", "documentation_labels", "system_number", "local_tenant_id"} 32 upsertableColumns = []string{"name", "description", "status_condition", "system_status", "provider_name", "base_url", "application_namespace", "labels", "tenant_business_type_id"} 33 matchingSystemColumns = []string{"system_number"} 34 ) 35 36 // EntityConverter missing godoc 37 // 38 //go:generate mockery --name=EntityConverter --output=automock --outpkg=automock --case=underscore --disable-version-string 39 type EntityConverter interface { 40 ToEntity(in *model.Application) (*Entity, error) 41 FromEntity(entity *Entity) *model.Application 42 } 43 44 type pgRepository struct { 45 existQuerier repo.ExistQuerier 46 ownerExistQuerier repo.ExistQuerier 47 singleGetter repo.SingleGetter 48 globalGetter repo.SingleGetterGlobal 49 globalDeleter repo.DeleterGlobal 50 lister repo.Lister 51 listeningAppsLister repo.Lister 52 listerGlobal repo.ListerGlobal 53 deleter repo.Deleter 54 pageableQuerier repo.PageableQuerier 55 globalPageableQuerier repo.PageableQuerierGlobal 56 creator repo.Creator 57 updater repo.Updater 58 globalUpdater repo.UpdaterGlobal 59 upserter repo.Upserter 60 trustedUpserter repo.Upserter 61 conv EntityConverter 62 } 63 64 // NewRepository missing godoc 65 func NewRepository(conv EntityConverter) *pgRepository { 66 return &pgRepository{ 67 existQuerier: repo.NewExistQuerier(applicationTable), 68 ownerExistQuerier: repo.NewExistQuerierWithOwnerCheck(applicationTable), 69 singleGetter: repo.NewSingleGetter(applicationTable, applicationColumns), 70 globalGetter: repo.NewSingleGetterGlobal(resource.Application, applicationTable, applicationColumns), 71 globalDeleter: repo.NewDeleterGlobal(resource.Application, applicationTable), 72 deleter: repo.NewDeleter(applicationTable), 73 lister: repo.NewLister(applicationTable, applicationColumns), 74 listeningAppsLister: repo.NewLister(listeningApplicationsView, applicationColumns), 75 listerGlobal: repo.NewListerGlobal(resource.Application, applicationTable, applicationColumns), 76 pageableQuerier: repo.NewPageableQuerier(applicationTable, applicationColumns), 77 globalPageableQuerier: repo.NewPageableQuerierGlobal(resource.Application, applicationTable, applicationColumns), 78 creator: repo.NewCreator(applicationTable, applicationColumns), 79 updater: repo.NewUpdater(applicationTable, updatableColumns, []string{"id"}), 80 globalUpdater: repo.NewUpdaterGlobal(resource.Application, applicationTable, updatableColumns, []string{"id"}), 81 upserter: repo.NewUpserter(applicationTable, applicationColumns, matchingSystemColumns, upsertableColumns), 82 trustedUpserter: repo.NewTrustedUpserter(applicationTable, applicationColumns, matchingSystemColumns, upsertableColumns), 83 conv: conv, 84 } 85 } 86 87 // Exists missing godoc 88 func (r *pgRepository) Exists(ctx context.Context, tenant, id string) (bool, error) { 89 return r.existQuerier.Exists(ctx, resource.Application, tenant, repo.Conditions{repo.NewEqualCondition("id", id)}) 90 } 91 92 // OwnerExists checks if application with given id and tenant exists and has owner access 93 func (r *pgRepository) OwnerExists(ctx context.Context, tenant, id string) (bool, error) { 94 return r.ownerExistQuerier.Exists(ctx, resource.Application, tenant, repo.Conditions{repo.NewEqualCondition("id", id)}) 95 } 96 97 // Delete missing godoc 98 func (r *pgRepository) Delete(ctx context.Context, tenant, id string) error { 99 opMode := operation.ModeFromCtx(ctx) 100 if opMode == graphql.OperationModeAsync { 101 app, err := r.GetByID(ctx, tenant, id) 102 if err != nil { 103 return err 104 } 105 106 app.SetReady(false) 107 app.SetError("") 108 if app.GetDeletedAt().IsZero() { // Needed for the tests but might be useful for the production also 109 app.SetDeletedAt(time.Now()) 110 } 111 112 return r.Update(ctx, tenant, app) 113 } 114 115 return r.deleter.DeleteOne(ctx, resource.Application, tenant, repo.Conditions{repo.NewEqualCondition("id", id)}) 116 } 117 118 // DeleteGlobal missing godoc 119 func (r *pgRepository) DeleteGlobal(ctx context.Context, id string) error { 120 opMode := operation.ModeFromCtx(ctx) 121 if opMode == graphql.OperationModeAsync { 122 app, err := r.GetGlobalByID(ctx, id) 123 if err != nil { 124 return err 125 } 126 127 app.SetReady(false) 128 app.SetError("") 129 if app.DeletedAt.IsZero() { // Needed for the tests but might be useful for the production also 130 app.SetDeletedAt(time.Now()) 131 } 132 133 return r.globalUpdater.UpdateSingleGlobal(ctx, app) 134 } 135 136 return r.globalDeleter.DeleteOneGlobal(ctx, repo.Conditions{repo.NewEqualCondition("id", id)}) 137 } 138 139 // GetByID missing godoc 140 func (r *pgRepository) GetByID(ctx context.Context, tenant, id string) (*model.Application, error) { 141 var appEnt Entity 142 if err := r.singleGetter.Get(ctx, resource.Application, tenant, repo.Conditions{repo.NewEqualCondition("id", id)}, repo.NoOrderBy, &appEnt); err != nil { 143 return nil, err 144 } 145 146 appModel := r.conv.FromEntity(&appEnt) 147 148 return appModel, nil 149 } 150 151 // GetByIDForUpdate returns the application with matching ID from the Compass DB and locks it exclusively until the transaction is finished. 152 func (r *pgRepository) GetByIDForUpdate(ctx context.Context, tenant, id string) (*model.Application, error) { 153 var appEnt Entity 154 if err := r.singleGetter.GetForUpdate(ctx, resource.Application, tenant, repo.Conditions{repo.NewEqualCondition("id", id)}, repo.NoOrderBy, &appEnt); err != nil { 155 return nil, err 156 } 157 158 appModel := r.conv.FromEntity(&appEnt) 159 160 return appModel, nil 161 } 162 163 // GetBySystemNumber returns an application retrieved by systemNumber from the Compass DB 164 func (r *pgRepository) GetBySystemNumber(ctx context.Context, tenant, systemNumber string) (*model.Application, error) { 165 var appEnt Entity 166 if err := r.singleGetter.Get(ctx, resource.Application, tenant, repo.Conditions{repo.NewEqualCondition("system_number", systemNumber)}, repo.NoOrderBy, &appEnt); err != nil { 167 return nil, err 168 } 169 170 appModel := r.conv.FromEntity(&appEnt) 171 172 return appModel, nil 173 } 174 175 // GetGlobalByID missing godoc 176 func (r *pgRepository) GetGlobalByID(ctx context.Context, id string) (*model.Application, error) { 177 var appEnt Entity 178 if err := r.globalGetter.GetGlobal(ctx, repo.Conditions{repo.NewEqualCondition("id", id)}, repo.NoOrderBy, &appEnt); err != nil { 179 return nil, err 180 } 181 182 appModel := r.conv.FromEntity(&appEnt) 183 184 return appModel, nil 185 } 186 187 // GetByFilter retrieves Application matching on the given label filters 188 func (r *pgRepository) GetByFilter(ctx context.Context, tenant string, filter []*labelfilter.LabelFilter) (*model.Application, error) { 189 var appEnt Entity 190 191 tenantID, err := uuid.Parse(tenant) 192 if err != nil { 193 return nil, errors.Wrap(err, "while parsing tenant as UUID") 194 } 195 196 filterSubquery, args, err := label.FilterQuery(model.ApplicationLabelableObject, label.IntersectSet, tenantID, filter) 197 if err != nil { 198 return nil, errors.Wrap(err, "while building filter query") 199 } 200 201 var conditions repo.Conditions 202 if filterSubquery != "" { 203 conditions = append(conditions, repo.NewInConditionForSubQuery("id", filterSubquery, args)) 204 } 205 206 if err = r.singleGetter.Get(ctx, resource.Application, tenant, conditions, repo.NoOrderBy, &appEnt); err != nil { 207 return nil, err 208 } 209 210 appModel := r.conv.FromEntity(&appEnt) 211 212 return appModel, nil 213 } 214 215 // ListAll missing godoc 216 func (r *pgRepository) ListAll(ctx context.Context, tenantID string) ([]*model.Application, error) { 217 var entities EntityCollection 218 219 err := r.lister.List(ctx, resource.Application, tenantID, &entities) 220 221 if err != nil { 222 return nil, err 223 } 224 225 return r.multipleFromEntities(entities) 226 } 227 228 // ListAllByFilter retrieves all applications matching on the given label filters 229 func (r *pgRepository) ListAllByFilter(ctx context.Context, tenant string, filter []*labelfilter.LabelFilter) ([]*model.Application, error) { 230 var entities EntityCollection 231 232 tenantID, err := uuid.Parse(tenant) 233 if err != nil { 234 return nil, errors.Wrap(err, "while parsing tenant as UUID") 235 } 236 237 filterSubquery, args, err := label.FilterQuery(model.ApplicationLabelableObject, label.IntersectSet, tenantID, filter) 238 if err != nil { 239 return nil, errors.Wrap(err, "while building filter query") 240 } 241 242 var conditions repo.Conditions 243 if filterSubquery != "" { 244 conditions = append(conditions, repo.NewInConditionForSubQuery("id", filterSubquery, args)) 245 } 246 247 if err = r.lister.List(ctx, resource.Application, tenant, &entities, conditions...); err != nil { 248 return nil, err 249 } 250 251 return r.multipleFromEntities(entities) 252 } 253 254 // ListAllByApplicationTemplateID retrieves all applications which have the given app template id 255 func (r *pgRepository) ListAllByApplicationTemplateID(ctx context.Context, applicationTemplateID string) ([]*model.Application, error) { 256 var appsCollection EntityCollection 257 258 conditions := repo.Conditions{ 259 repo.NewEqualCondition("app_template_id", applicationTemplateID), 260 } 261 if err := r.listerGlobal.ListGlobal(ctx, &appsCollection, conditions...); err != nil { 262 return nil, err 263 } 264 265 items := make([]*model.Application, 0, len(appsCollection)) 266 267 for _, appEnt := range appsCollection { 268 m := r.conv.FromEntity(&appEnt) 269 items = append(items, m) 270 } 271 272 return items, nil 273 } 274 275 // List missing godoc 276 func (r *pgRepository) List(ctx context.Context, tenant string, filter []*labelfilter.LabelFilter, pageSize int, cursor string) (*model.ApplicationPage, error) { 277 var appsCollection EntityCollection 278 tenantID, err := uuid.Parse(tenant) 279 if err != nil { 280 return nil, errors.Wrap(err, "while parsing tenant as UUID") 281 } 282 filterSubquery, args, err := label.FilterQuery(model.ApplicationLabelableObject, label.IntersectSet, tenantID, filter) 283 if err != nil { 284 return nil, errors.Wrap(err, "while building filter query") 285 } 286 287 var conditions repo.Conditions 288 if filterSubquery != "" { 289 conditions = append(conditions, repo.NewInConditionForSubQuery("id", filterSubquery, args)) 290 } 291 292 page, totalCount, err := r.pageableQuerier.List(ctx, resource.Application, tenant, pageSize, cursor, "id", &appsCollection, conditions...) 293 294 if err != nil { 295 return nil, err 296 } 297 298 items := make([]*model.Application, 0, len(appsCollection)) 299 300 for _, appEnt := range appsCollection { 301 m := r.conv.FromEntity(&appEnt) 302 items = append(items, m) 303 } 304 return &model.ApplicationPage{ 305 Data: items, 306 TotalCount: totalCount, 307 PageInfo: page}, nil 308 } 309 310 // ListGlobal missing godoc 311 func (r *pgRepository) ListGlobal(ctx context.Context, pageSize int, cursor string) (*model.ApplicationPage, error) { 312 var appsCollection EntityCollection 313 314 page, totalCount, err := r.globalPageableQuerier.ListGlobal(ctx, pageSize, cursor, "id", &appsCollection) 315 316 if err != nil { 317 return nil, err 318 } 319 320 items := make([]*model.Application, 0, len(appsCollection)) 321 322 for _, appEnt := range appsCollection { 323 m := r.conv.FromEntity(&appEnt) 324 items = append(items, m) 325 } 326 return &model.ApplicationPage{ 327 Data: items, 328 TotalCount: totalCount, 329 PageInfo: page}, nil 330 } 331 332 // ListByScenarios missing godoc 333 func (r *pgRepository) ListByScenarios(ctx context.Context, tenant uuid.UUID, scenarios []string, pageSize int, cursor string, hidingSelectors map[string][]string) (*model.ApplicationPage, error) { 334 var appsCollection EntityCollection 335 336 // Scenarios query part 337 scenariosFilters := make([]*labelfilter.LabelFilter, 0, len(scenarios)) 338 for _, scenarioValue := range scenarios { 339 query := fmt.Sprintf(`$[*] ? (@ == "%s")`, scenarioValue) 340 scenariosFilters = append(scenariosFilters, labelfilter.NewForKeyWithQuery(model.ScenariosKey, query)) 341 } 342 343 scenariosSubquery, scenariosArgs, err := label.FilterQuery(model.ApplicationLabelableObject, label.UnionSet, tenant, scenariosFilters) 344 if err != nil { 345 return nil, errors.Wrap(err, "while creating scenarios filter query") 346 } 347 348 // Application Hide query part 349 var appHideFilters []*labelfilter.LabelFilter 350 for key, values := range hidingSelectors { 351 for _, value := range values { 352 appHideFilters = append(appHideFilters, labelfilter.NewForKeyWithQuery(key, fmt.Sprintf(`"%s"`, value))) 353 } 354 } 355 356 appHideSubquery, appHideArgs, err := label.FilterSubquery(model.ApplicationLabelableObject, label.ExceptSet, tenant, appHideFilters) 357 if err != nil { 358 return nil, errors.Wrap(err, "while creating scenarios filter query") 359 } 360 361 // Combining both queries 362 combinedQuery := scenariosSubquery + appHideSubquery 363 combinedArgs := append(scenariosArgs, appHideArgs...) 364 365 var conditions repo.Conditions 366 if combinedQuery != "" { 367 conditions = append(conditions, repo.NewInConditionForSubQuery("id", combinedQuery, combinedArgs)) 368 } 369 370 page, totalCount, err := r.pageableQuerier.List(ctx, resource.Application, tenant.String(), pageSize, cursor, "id", &appsCollection, conditions...) 371 372 if err != nil { 373 return nil, err 374 } 375 376 items := make([]*model.Application, 0, len(appsCollection)) 377 378 for _, appEnt := range appsCollection { 379 m := r.conv.FromEntity(&appEnt) 380 items = append(items, m) 381 } 382 return &model.ApplicationPage{ 383 Data: items, 384 TotalCount: totalCount, 385 PageInfo: page}, nil 386 } 387 388 // ListByScenariosNoPaging lists all applications that are in any of the given scenarios 389 func (r *pgRepository) ListByScenariosNoPaging(ctx context.Context, tenant string, scenarios []string) ([]*model.Application, error) { 390 tenantUUID, err := uuid.Parse(tenant) 391 if err != nil { 392 return nil, apperrors.NewInvalidDataError("tenantID is not UUID") 393 } 394 395 var entities EntityCollection 396 397 // Scenarios query part 398 scenariosFilters := make([]*labelfilter.LabelFilter, 0, len(scenarios)) 399 for _, scenarioValue := range scenarios { 400 query := fmt.Sprintf(`$[*] ? (@ == "%s")`, scenarioValue) 401 scenariosFilters = append(scenariosFilters, labelfilter.NewForKeyWithQuery(model.ScenariosKey, query)) 402 } 403 404 scenariosSubquery, scenariosArgs, err := label.FilterQuery(model.ApplicationLabelableObject, label.UnionSet, tenantUUID, scenariosFilters) 405 if err != nil { 406 return nil, errors.Wrap(err, "while creating scenarios filter query") 407 } 408 409 var conditions repo.Conditions 410 if scenariosSubquery != "" { 411 conditions = append(conditions, repo.NewInConditionForSubQuery("id", scenariosSubquery, scenariosArgs)) 412 } 413 414 if err = r.lister.List(ctx, resource.Application, tenant, &entities, conditions...); err != nil { 415 return nil, err 416 } 417 418 items := make([]*model.Application, 0, len(entities)) 419 420 for _, appEnt := range entities { 421 m := r.conv.FromEntity(&appEnt) 422 items = append(items, m) 423 } 424 425 return items, nil 426 } 427 428 // ListByScenariosAndIDs lists all apps with given IDs that are in any of the given scenarios 429 func (r *pgRepository) ListByScenariosAndIDs(ctx context.Context, tenant string, scenarios []string, ids []string) ([]*model.Application, error) { 430 if len(scenarios) == 0 || len(ids) == 0 { 431 return nil, nil 432 } 433 tenantUUID, err := uuid.Parse(tenant) 434 if err != nil { 435 return nil, apperrors.NewInvalidDataError("tenantID is not UUID") 436 } 437 438 var entities EntityCollection 439 440 // Scenarios query part 441 scenariosFilters := make([]*labelfilter.LabelFilter, 0, len(scenarios)) 442 for _, scenarioValue := range scenarios { 443 query := fmt.Sprintf(`$[*] ? (@ == "%s")`, scenarioValue) 444 scenariosFilters = append(scenariosFilters, labelfilter.NewForKeyWithQuery(model.ScenariosKey, query)) 445 } 446 447 scenariosSubquery, scenariosArgs, err := label.FilterQuery(model.ApplicationLabelableObject, label.UnionSet, tenantUUID, scenariosFilters) 448 if err != nil { 449 return nil, errors.Wrap(err, "while creating scenarios filter query") 450 } 451 452 var conditions repo.Conditions 453 if scenariosSubquery != "" { 454 conditions = append(conditions, repo.NewInConditionForSubQuery("id", scenariosSubquery, scenariosArgs)) 455 } 456 457 conditions = append(conditions, repo.NewInConditionForStringValues("id", ids)) 458 459 if err := r.lister.List(ctx, resource.Application, tenant, &entities, conditions...); err != nil { 460 return nil, err 461 } 462 463 items := make([]*model.Application, 0, len(entities)) 464 465 for _, appEnt := range entities { 466 m := r.conv.FromEntity(&appEnt) 467 items = append(items, m) 468 } 469 470 return items, nil 471 } 472 473 // ListAllByIDs lists all apps with given IDs 474 func (r *pgRepository) ListAllByIDs(ctx context.Context, tenantID string, ids []string) ([]*model.Application, error) { 475 if len(ids) == 0 { 476 return nil, nil 477 } 478 479 var entities EntityCollection 480 err := r.lister.List(ctx, resource.Application, tenantID, &entities, repo.NewInConditionForStringValues("id", ids)) 481 482 if err != nil { 483 return nil, err 484 } 485 486 return r.multipleFromEntities(entities) 487 } 488 489 // ListListeningApplications lists all application that either have webhook of type whType, or their application template has a webhook of type whType 490 func (r *pgRepository) ListListeningApplications(ctx context.Context, tenant string, whType model.WebhookType) ([]*model.Application, error) { 491 var entities EntityCollection 492 493 conditions := repo.Conditions{ 494 repo.NewEqualCondition("webhook_type", whType), 495 } 496 497 if err := r.listeningAppsLister.List(ctx, resource.Application, tenant, &entities, conditions...); err != nil { 498 return nil, err 499 } 500 501 return r.multipleFromEntities(entities) 502 } 503 504 // Create missing godoc 505 func (r *pgRepository) Create(ctx context.Context, tenant string, model *model.Application) error { 506 if model == nil { 507 return apperrors.NewInternalError("model can not be empty") 508 } 509 510 log.C(ctx).Debugf("Converting Application model with id %s to entity", model.ID) 511 appEnt, err := r.conv.ToEntity(model) 512 if err != nil { 513 return errors.Wrap(err, "while converting to Application entity") 514 } 515 516 log.C(ctx).Debugf("Persisting Application entity with id %s to db", model.ID) 517 return r.creator.Create(ctx, resource.Application, tenant, appEnt) 518 } 519 520 // Update missing godoc 521 func (r *pgRepository) Update(ctx context.Context, tenant string, model *model.Application) error { 522 return r.updateSingle(ctx, tenant, model, false) 523 } 524 525 // Upsert inserts application for given tenant or update it if it already exists 526 func (r *pgRepository) Upsert(ctx context.Context, tenant string, model *model.Application) (string, error) { 527 return r.genericUpsert(ctx, tenant, model, r.upserter) 528 } 529 530 // TrustedUpsert inserts application for given tenant or update it if it already exists ignoring tenant isolation 531 func (r *pgRepository) TrustedUpsert(ctx context.Context, tenant string, model *model.Application) (string, error) { 532 return r.genericUpsert(ctx, tenant, model, r.trustedUpserter) 533 } 534 535 // TechnicalUpdate missing godoc 536 func (r *pgRepository) TechnicalUpdate(ctx context.Context, model *model.Application) error { 537 return r.updateSingle(ctx, "", model, true) 538 } 539 540 func (r *pgRepository) genericUpsert(ctx context.Context, tenant string, model *model.Application, upserter repo.Upserter) (string, error) { 541 if model == nil { 542 return "", apperrors.NewInternalError("model can not be empty") 543 } 544 545 log.C(ctx).Debugf("Converting Application model with id %s to entity", model.ID) 546 appEnt, err := r.conv.ToEntity(model) 547 if err != nil { 548 return "", errors.Wrap(err, "while converting to Application entity") 549 } 550 551 log.C(ctx).Debugf("Upserting Application entity with id %s to db", model.ID) 552 return upserter.Upsert(ctx, resource.Application, tenant, appEnt) 553 } 554 555 func (r *pgRepository) updateSingle(ctx context.Context, tenant string, model *model.Application, isTechnical bool) error { 556 if model == nil { 557 return apperrors.NewInternalError("model can not be empty") 558 } 559 560 log.C(ctx).Debugf("Converting Application model with id %s to entity", model.ID) 561 appEnt, err := r.conv.ToEntity(model) 562 if err != nil { 563 return errors.Wrap(err, "while converting to Application entity") 564 } 565 566 log.C(ctx).Debugf("Persisting updated Application entity with id %s to db", model.ID) 567 if isTechnical { 568 return r.globalUpdater.TechnicalUpdate(ctx, appEnt) 569 } 570 return r.updater.UpdateSingle(ctx, resource.Application, tenant, appEnt) 571 } 572 573 func (r *pgRepository) multipleFromEntities(entities EntityCollection) ([]*model.Application, error) { 574 items := make([]*model.Application, 0, len(entities)) 575 for _, ent := range entities { 576 m := r.conv.FromEntity(&ent) 577 items = append(items, m) 578 } 579 return items, nil 580 }