Skip to content

Commit ec526d6

Browse files
tas50claude
andauthored
feat: list versions of a single cookbook and cookbook artifact (#31)
Add the two read endpoints from the Chef Server API that were unexposed: GET /organizations/ORG/cookbooks/NAME -> Cookbooks.GetVersions GET /organizations/ORG/cookbook_artifacts/NAME -> CookbookArtifacts.GetVersions Both return the versions/identifiers of one named item, unwrapped from the server's single-key {name: {url, versions}} envelope into the existing CookbookListEntry / CookbookArtifactListEntry types. Cookbooks.GetVersions accepts the documented num_versions filter ("" / "n" / "all"). Previously callers could only List() every cookbook or Get() one specific version/identifier; there was no way to ask for one cookbook's version set. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f680867 commit ec526d6

5 files changed

Lines changed: 146 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ following endpoint families are implemented:
2828
| `c.Associations` | `/organizations/O/users`, `/association_requests`, `/users/U/...` | Members (ListMembers/GetMember/AddMember/RemoveMember), org invites (ListInvites/Invite/RescindInvite), user invites (ListUserInvites/UserInviteCount/RespondInvite) and ListUserOrgs |
2929
| `c.Clients` | `/clients` | List / Get / Create / Update / Delete / Reregister |
3030
| `c.Containers` | `/containers` | List / Get / Create / Delete |
31-
| `c.Cookbooks` | `/cookbooks` | List / Get (with metadata) / Delete / Upload (sandbox flow) / Download / ListLatest / ListRecipes |
32-
| `c.CookbookArtifacts`| `/cookbook_artifacts` | List / Get (with metadata) / Delete / Upload |
31+
| `c.Cookbooks` | `/cookbooks` | List / GetVersions (one cookbook, `num_versions`) / Get (with metadata) / Delete / Upload (sandbox flow) / Download / ListLatest / ListRecipes |
32+
| `c.CookbookArtifacts`| `/cookbook_artifacts` | List / GetVersions (one artifact) / Get (with metadata) / Delete / Upload |
3333
| `c.DataBags` | `/data` | List / Create / Delete; per-bag Items handle for CRUD |
3434
| `c.Environments` | `/environments` | List / Get / Create / Update / Delete / ListCookbooks / GetCookbook / CookbookVersions / ListNodes / ListRecipes / RoleRunList |
3535
| `c.Groups` | `/groups` | List / Get / Create / Update / Delete |

cookbook_artifacts.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ func (s *CookbookArtifactsService) List(ctx context.Context) (map[string]Cookboo
2727
s.client.orgPath("/cookbook_artifacts"), nil)
2828
}
2929

30+
// GetVersions returns the available identifiers of a single cookbook artifact
31+
// (GET /cookbook_artifacts/NAME), unwrapped from the server's single-key
32+
// {name: {url, versions}} envelope.
33+
func (s *CookbookArtifactsService) GetVersions(ctx context.Context, name string) (*CookbookArtifactListEntry, *Response, error) {
34+
m, resp, err := do[map[string]CookbookArtifactListEntry](ctx, s.client, "GET",
35+
s.client.orgPath("/cookbook_artifacts/"+name), nil)
36+
if err != nil {
37+
return nil, resp, err
38+
}
39+
entry, ok := m[name]
40+
if !ok {
41+
return nil, resp, fmt.Errorf("cinc: cookbook artifact %q missing from response", name)
42+
}
43+
return &entry, resp, nil
44+
}
45+
3046
// Get retrieves a single cookbook artifact by name and identifier.
3147
func (s *CookbookArtifactsService) Get(ctx context.Context, name, identifier string) (*Cookbook, *Response, error) {
3248
cb, resp, err := do[Cookbook](ctx, s.client, "GET",

cookbook_artifacts_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cinc
22

33
import (
44
"context"
5+
"errors"
56
"io"
67
"net/http"
78
"os"
@@ -11,6 +12,45 @@ import (
1112
"github.com/tas50/cinc-api/internal/cinctest"
1213
)
1314

15+
func TestCookbookArtifacts_GetVersions(t *testing.T) {
16+
t.Run("unwraps single-key envelope", func(t *testing.T) {
17+
srv := cinctest.New(t)
18+
srv.Handle("GET /organizations/o/cookbook_artifacts/rabbitmq", cinctest.Route{
19+
Body: `{"rabbitmq":{"url":"http://x/rabbitmq","versions":[` +
20+
`{"url":"http://x/rabbitmq/0bd7","identifier":"0bd7"},` +
21+
`{"url":"http://x/rabbitmq/0e10","identifier":"0e10"}]}}`,
22+
})
23+
c := newTestClient(t, srv.Server)
24+
25+
entry, resp, err := c.CookbookArtifacts.GetVersions(context.Background(), "rabbitmq")
26+
if err != nil {
27+
t.Fatalf("GetVersions: %v", err)
28+
}
29+
if resp.StatusCode != 200 {
30+
t.Fatalf("status = %d", resp.StatusCode)
31+
}
32+
if entry == nil || len(entry.Versions) != 2 || entry.Versions[0].Identifier != "0bd7" {
33+
t.Fatalf("entry = %+v, want 2 identifiers", entry)
34+
}
35+
})
36+
37+
t.Run("propagates 404", func(t *testing.T) {
38+
srv := cinctest.New(t)
39+
srv.Handle("GET /organizations/o/cookbook_artifacts/missing", cinctest.Route{
40+
Status: 404, Body: `{"error":["not found"]}`,
41+
})
42+
c := newTestClient(t, srv.Server)
43+
44+
entry, _, err := c.CookbookArtifacts.GetVersions(context.Background(), "missing")
45+
if !errors.Is(err, ErrNotFound) {
46+
t.Fatalf("err = %v, want ErrNotFound", err)
47+
}
48+
if entry != nil {
49+
t.Errorf("entry = %+v, want nil on error", entry)
50+
}
51+
})
52+
}
53+
1454
func TestCookbookArtifacts_Upload_ErrorWrapped(t *testing.T) {
1555
// Build a minimal cookbook so LocalCookbookFromDir succeeds, then have the
1656
// fake server fail the sandbox POST so Upload wraps the error.

cookbooks.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"io/fs"
88
"net/http"
9+
"net/url"
910
"os"
1011
"path/filepath"
1112
"strings"
@@ -158,6 +159,27 @@ func (s *CookbooksService) ListRecipes(ctx context.Context) ([]string, *Response
158159
s.client.orgPath("/cookbooks/_recipes"), nil)
159160
}
160161

162+
// GetVersions returns the available versions of a single cookbook
163+
// (GET /cookbooks/NAME), unwrapped from the server's single-key
164+
// {name: {url, versions}} envelope. numVersions limits the versions returned
165+
// ("" for the server default of one, "all" for every version, or "n");
166+
// versions come back newest-first.
167+
func (s *CookbooksService) GetVersions(ctx context.Context, name, numVersions string) (*CookbookListEntry, *Response, error) {
168+
path := s.client.orgPath("/cookbooks/" + name)
169+
if numVersions != "" {
170+
path += "?num_versions=" + url.QueryEscape(numVersions)
171+
}
172+
m, resp, err := do[map[string]CookbookListEntry](ctx, s.client, "GET", path, nil)
173+
if err != nil {
174+
return nil, resp, err
175+
}
176+
entry, ok := m[name]
177+
if !ok {
178+
return nil, resp, fmt.Errorf("cinc: cookbook %q missing from response", name)
179+
}
180+
return &entry, resp, nil
181+
}
182+
161183
// Get retrieves a single cookbook version manifest.
162184
func (s *CookbooksService) Get(ctx context.Context, name, version string) (*Cookbook, *Response, error) {
163185
cb, resp, err := do[Cookbook](ctx, s.client, "GET",

cookbooks_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cinc
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -15,6 +16,71 @@ import (
1516
"github.com/tas50/cinc-api/internal/cinctest"
1617
)
1718

19+
func TestCookbooks_GetVersions(t *testing.T) {
20+
t.Run("unwraps single-key envelope and sends num_versions", func(t *testing.T) {
21+
srv := cinctest.New(t)
22+
var gotNumVersions string
23+
srv.Handle("GET /organizations/o/cookbooks/apache2", cinctest.Route{
24+
Body: `{"apache2":{"url":"http://x/apache2","versions":[` +
25+
`{"url":"http://x/apache2/5.1.0","version":"5.1.0"},` +
26+
`{"url":"http://x/apache2/4.2.0","version":"4.2.0"}]}}`,
27+
Assert: func(t *testing.T, r *http.Request, _ []byte) {
28+
gotNumVersions = r.URL.Query().Get("num_versions")
29+
},
30+
})
31+
c := newTestClient(t, srv.Server)
32+
33+
entry, resp, err := c.Cookbooks.GetVersions(context.Background(), "apache2", "all")
34+
if err != nil {
35+
t.Fatalf("GetVersions: %v", err)
36+
}
37+
if resp.StatusCode != 200 {
38+
t.Fatalf("status = %d", resp.StatusCode)
39+
}
40+
if gotNumVersions != "all" {
41+
t.Errorf("num_versions = %q, want all", gotNumVersions)
42+
}
43+
if entry == nil || len(entry.Versions) != 2 || entry.Versions[0].Version != "5.1.0" {
44+
t.Fatalf("entry = %+v, want 2 versions newest-first", entry)
45+
}
46+
})
47+
48+
t.Run("omits num_versions when empty", func(t *testing.T) {
49+
srv := cinctest.New(t)
50+
var hadParam bool
51+
srv.Handle("GET /organizations/o/cookbooks/nginx", cinctest.Route{
52+
Body: `{"nginx":{"url":"http://x/nginx","versions":[{"url":"http://x/nginx/1.0.0","version":"1.0.0"}]}}`,
53+
Assert: func(t *testing.T, r *http.Request, _ []byte) {
54+
_, hadParam = r.URL.Query()["num_versions"]
55+
},
56+
})
57+
c := newTestClient(t, srv.Server)
58+
59+
if _, _, err := c.Cookbooks.GetVersions(context.Background(), "nginx", ""); err != nil {
60+
t.Fatalf("GetVersions: %v", err)
61+
}
62+
if hadParam {
63+
t.Error("num_versions should be absent when numVersions is empty")
64+
}
65+
})
66+
67+
t.Run("propagates 404", func(t *testing.T) {
68+
srv := cinctest.New(t)
69+
srv.Handle("GET /organizations/o/cookbooks/missing", cinctest.Route{
70+
Status: 404, Body: `{"error":["not found"]}`,
71+
})
72+
c := newTestClient(t, srv.Server)
73+
74+
entry, _, err := c.Cookbooks.GetVersions(context.Background(), "missing", "")
75+
if !errors.Is(err, ErrNotFound) {
76+
t.Fatalf("err = %v, want ErrNotFound", err)
77+
}
78+
if entry != nil {
79+
t.Errorf("entry = %+v, want nil on error", entry)
80+
}
81+
})
82+
}
83+
1884
func TestCookbooks_GetAndList(t *testing.T) {
1985
srv := cinctest.New(t)
2086
srv.Handle("GET /organizations/o/cookbooks",

0 commit comments

Comments
 (0)