Skip to content

Commit

Permalink
Get collection document structure (#35)
Browse files Browse the repository at this point in the history
Add new API methods for getting a summary list of all documents
associated with a given collection.

Also expose above functionality via a new cli sub-command: `docs`.

Extra changes:
- Rename `fetch` subcommand to more suitable `info`. This complements
the newly added `docs` very well.

- Use `json.MarshalIndent` to output a bit prettier JSON data.
  • Loading branch information
rsjethani committed Aug 12, 2023
1 parent 70e8174 commit 66a32e6
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 12 deletions.
40 changes: 40 additions & 0 deletions collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ func newCollectionsClient(sl *sling.Sling) *CollectionsClient {
return &CollectionsClient{sl: sl}
}

// DocumentStructure gives access to id's document structure.
// API Reference: https://www.getoutline.com/developers#tag/Collections/paths/~1collections.documents/post
func (cl *CollectionsClient) DocumentStructure(id CollectionID) *CollectionsDocumentStructureClient {
return newCollectionsDocumentStructureClient(cl.sl, id)
}

func (cl *CollectionsClient) Get(id CollectionID) *CollectionsGetClient {
return newCollectionsGetClient(cl.sl, id)
}
Expand All @@ -31,6 +37,40 @@ func (cl *CollectionsClient) Create(name string) *CollectionsCreateClient {
return newCollectionsCreateClient(cl.sl, name)
}

type CollectionsDocumentStructureClient struct {
sl *sling.Sling
}

func newCollectionsDocumentStructureClient(sl *sling.Sling, id CollectionID) *CollectionsDocumentStructureClient {
data := struct {
ID CollectionID `json:"id"`
}{ID: id}

copy := sl.New()
copy.Post(common.CollectionsStructureEndpoint()).BodyJSON(&data)

return &CollectionsDocumentStructureClient{sl: copy}
}

type DocumentStructure []DocumentSummary

// Do makes the actual request for getting the collection's document structure.
func (cl *CollectionsDocumentStructureClient) Do(ctx context.Context) (DocumentStructure, error) {
success := &struct {
Data DocumentStructure `json:"data"`
}{}

br, err := request(ctx, cl.sl, success)
if err != nil {
return nil, fmt.Errorf("failed making HTTP request: %w", err)
}
if br != nil {
return nil, fmt.Errorf("bad response: %w", &apiError{br: *br})
}

return success.Data, nil
}

type CollectionsGetClient struct {
sl *sling.Sling
}
Expand Down
55 changes: 47 additions & 8 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,23 @@ func collectionCmd(rootCmd *cobra.Command) *cobra.Command {
Args: cobra.MinimumNArgs(1),
}

fetchSubCmd := collectionCmdFetch(rootCmd)
collectionCmd.AddCommand(fetchSubCmd)
docsSubCmd := collectionCmdDocs(rootCmd)
collectionCmd.AddCommand(docsSubCmd)

infoSubCmd := collectionCmdInfo(rootCmd)
collectionCmd.AddCommand(infoSubCmd)

createSubCmd := collectionCmdCreate(rootCmd)
collectionCmd.AddCommand(createSubCmd)

return collectionCmd
}

func collectionCmdFetch(rootCmd *cobra.Command) *cobra.Command {
func collectionCmdDocs(rootCmd *cobra.Command) *cobra.Command {
return &cobra.Command{
Use: "fetch",
Short: "Fetch collection details",
Long: "Fetch collection details for given ID and prints as json to stdout",
Use: "docs",
Short: "Get document structure",
Long: "Get a summary of associated documents (and children)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
baseUrl, err := rootCmd.Flags().GetString(flagNameBaseUrl)
Expand All @@ -66,14 +69,49 @@ func collectionCmdFetch(rootCmd *cobra.Command) *cobra.Command {
if err != nil {
return fmt.Errorf("required flag '%s' not set: %w", flagNameApiKey, err)
}

client := outline.New(baseUrl, &http.Client{}, apiKey)
for _, colId := range args {
st, err := client.Collections().DocumentStructure(outline.CollectionID(colId)).Do(context.Background())
if err != nil {
return fmt.Errorf("can't get collection with id '%s': %w", colId, err)
}

b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("failed marshalling collection with id '%s: %w", colId, err)
}
fmt.Println(string(b))
}
return nil
},
}
}

func collectionCmdInfo(rootCmd *cobra.Command) *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Get collection info",
Long: "Get information about the collection",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
baseUrl, err := rootCmd.Flags().GetString(flagNameBaseUrl)
if err != nil {
return fmt.Errorf("required flag '%s' not set: %w", flagNameBaseUrl, err)
}
apiKey, err := rootCmd.Flags().GetString(flagNameApiKey)
if err != nil {
return fmt.Errorf("required flag '%s' not set: %w", flagNameApiKey, err)
}

client := outline.New(baseUrl, &http.Client{}, apiKey)
for _, colId := range args {
col, err := client.Collections().Get(outline.CollectionID(colId)).Do(context.Background())
if err != nil {
return fmt.Errorf("can't get collection with id '%s': %w", colId, err)
}

b, err := json.Marshal(col)
b, err := json.MarshalIndent(col, "", " ")
if err != nil {
return fmt.Errorf("failed marshalling collection with id '%s: %w", colId, err)
}
Expand All @@ -99,6 +137,7 @@ func collectionCmdCreate(rootCmd *cobra.Command) *cobra.Command {
if err != nil {
return fmt.Errorf("required flag '%s' not set: %w", flagNameApiKey, err)
}

client := outline.New(baseUrl, &http.Client{}, apiKey)

name := args[0]
Expand All @@ -107,7 +146,7 @@ func collectionCmdCreate(rootCmd *cobra.Command) *cobra.Command {
return fmt.Errorf("can't create collection with name '%s': %w", name, err)
}

b, err := json.Marshal(col)
b, err := json.MarshalIndent(col, "", " ")
if err != nil {
return fmt.Errorf("failed marshalling collection with name '%s: %w", name, err)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ func HdrValueAuthorization(key string) string {
return "Bearer " + key
}

func CollectionsStructureEndpoint() string {
return "collections.documents"
}

func CollectionsGetEndpoint() string {
return "collections.info"
}
Expand Down
8 changes: 8 additions & 0 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ type (
TemplateID string
)

// DocumentSummary represents summary of a document (and its children) that is part of a collection.
type DocumentSummary struct {
ID DocumentID `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Children []DocumentSummary `json:"children"`
}

// Document represents an outline document.
type Document struct {
ID DocumentID `json:"id"`
Expand Down
119 changes: 115 additions & 4 deletions package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,95 @@ const (
testBaseURL string = "https://localhost.123"
)

func TestClientCollectionsStructure_failed(t *testing.T) {
tests := map[string]struct {
isTemporary bool
rt http.RoundTripper
}{
"HTTP request failed": {
isTemporary: false,
rt: &testutils.MockRoundTripper{
RoundTripFn: func(r *http.Request) (*http.Response, error) {
return nil, &net.DNSError{}
},
},
},
"server side error": {
isTemporary: true,
rt: &testutils.MockRoundTripper{
RoundTripFn: func(r *http.Request) (*http.Response, error) {
return &http.Response{
Request: r,
StatusCode: http.StatusServiceUnavailable,
ContentLength: -1,
Body: io.NopCloser(strings.NewReader("service unavailable")),
}, nil
},
},
},
"client side error": {
isTemporary: false,
rt: &testutils.MockRoundTripper{
RoundTripFn: func(r *http.Request) (*http.Response, error) {
return &http.Response{
Request: r,
ContentLength: -1,
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader("unauthorized key")),
}, nil
},
},
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
hc := &http.Client{}
hc.Transport = test.rt
cl := outline.New(testBaseURL, hc, testApiKey)
col, err := cl.Collections().DocumentStructure("collection id").Do(context.Background())
assert.Nil(t, col)
require.NotNil(t, err)
assert.Equal(t, test.isTemporary, outline.IsTemporary(err))
})
}
}

func TestClientCollectionsStructure(t *testing.T) {
testResponse := exampleCollectionsDocumentStructureResponse

// Prepare HTTP client with mocked transport.
hc := &http.Client{}
hc.Transport = &testutils.MockRoundTripper{RoundTripFn: func(r *http.Request) (*http.Response, error) {
// Assert request method and URL.
assert.Equal(t, http.MethodPost, r.Method)
u, err := url.JoinPath(testBaseURL, common.CollectionsStructureEndpoint())
require.NoError(t, err)
assert.Equal(t, u, r.URL.String())

testAssertHeaders(t, r.Header)
testAssertBody(t, r, fmt.Sprintf(`{"id":"%s"}`, "collection id"))

return &http.Response{
Request: r,
ContentLength: -1,
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(testResponse)),
}, nil
}}

cl := outline.New(testBaseURL, hc, testApiKey)
got, err := cl.Collections().DocumentStructure("collection id").Do(context.Background())
require.NoError(t, err)

// Manually unmarshal test response and see if we get same object via the API.
expected := struct {
Data outline.DocumentStructure `json:"data"`
}{}
require.NoError(t, json.Unmarshal([]byte(testResponse), &expected))
assert.Equal(t, expected.Data, got)
}

func TestClientCollectionsGet_failed(t *testing.T) {
tests := map[string]struct {
isTemporary bool
Expand Down Expand Up @@ -200,7 +289,6 @@ func TestClientCollectionsCreate(t *testing.T) {
assert.Equal(t, &expected.Data, got)
}


func TestDocumentsClientCreate(t *testing.T) {
testResponse := exampleDocumentsCreateResponse_1documents

Expand Down Expand Up @@ -235,9 +323,8 @@ func TestDocumentsClientCreate(t *testing.T) {
}{}
require.NoError(t, json.Unmarshal([]byte(testResponse), expected))
assert.Equal(t, &expected.Data, got)
}


}

func testAssertHeaders(t *testing.T, headers http.Header) {
t.Helper()
assert.Equal(t, headers.Get(common.HdrKeyAccept), common.HdrValueAccept)
Expand Down Expand Up @@ -364,3 +451,27 @@ const exampleDocumentsCreateResponse_1documents string = `{
"deletedAt": "2019-08-24T14:15:22Z"
}
}`

const exampleCollectionsDocumentStructureResponse string = `
{
"data": [
{
"id": "doc1",
"title": "Doc 1",
"url": "https://doc1.url"
},
{
"id": "doc2",
"title": "Doc 2",
"url": "https://doc2.url",
"children": [
{
"id": "doc2-1",
"title": "Doc 2-1",
"url": "https://doc2-1.url"
}
]
}
]
}
`

0 comments on commit 66a32e6

Please sign in to comment.