Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 402 additions & 0 deletions api/openapi.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ func openBrowser(url string) error {
return cmd.Start()
}

func callbackRedirectURI(addr net.Addr) string {
return fmt.Sprintf("http://%s/callback", addr.String())
}

// Login runs the full OAuth2 Authorization Code + PKCE flow.
// Returns a credential to store.
func Login(server string) (*config.Credential, error) {
Expand All @@ -274,8 +278,7 @@ func Login(server string) (*config.Credential, error) {
if err != nil {
return nil, err
}
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://localhost:%d/callback", port)
redirectURI := callbackRedirectURI(listener.Addr())

// Register client
clientInfo, err := registerClient(regEndpoint, redirectURI)
Expand Down
27 changes: 27 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,40 @@ package auth
import (
"crypto/rand"
"crypto/rsa"
"net"
"net/url"
"strconv"
"testing"
"time"

"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)

func TestCallbackRedirectURIMatchesLoopbackListener(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = listener.Close()
})

redirect, err := url.Parse(callbackRedirectURI(listener.Addr()))
if err != nil {
t.Fatal(err)
}
if redirect.Hostname() != "127.0.0.1" {
t.Fatalf("redirect hostname = %q, want 127.0.0.1", redirect.Hostname())
}
if redirect.Port() != strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) {
t.Fatalf("redirect port = %q, listener = %q", redirect.Port(), listener.Addr())
}
if redirect.Path != "/callback" {
t.Fatalf("redirect path = %q, want /callback", redirect.Path)
}
}

func TestVerifiedTokenToCredentialUsesFallbacks(t *testing.T) {
vt := &VerifiedToken{
AccessToken: "access",
Expand Down
89 changes: 48 additions & 41 deletions internal/cmd/course/course.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ func runCourseList(cmd *cobra.Command, opts courseListOpts) error {
}
return tui.TableResult{Rows: list.Rows, Total: list.Total, Page: list.Page}, nil
},
OnSelect: func(row map[string]any) error {
return runCourseView(cmd, fmt.Sprint(row["jwId"]))
},
EmptyMessage: "No courses found. Try a broader search.",
})
}
Expand Down Expand Up @@ -126,7 +129,7 @@ func courseListColumns() []output.Column {
{Header: "Code", Key: "code"},
{Header: "Name", Key: "namePrimary"},
{Header: "Level", Key: "educationLevel.name"},
{Header: "ID", Key: "id"},
{Header: "JW ID", Key: "jwId"},
}
}

Expand Down Expand Up @@ -161,46 +164,50 @@ func newCmdView() *cobra.Command {
Short: "View course details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false)
if err != nil {
return err
}
jwID, err := cmdutil.Int64PtrIfSet(args[0])
if err != nil {
return err
}
data, err := api.ParseResponseRaw(c.GetCourse(api.Ctx(), *jwID))
if err != nil {
return err
}
if output.IsJSON() {
return output.JSON(data)
}
m := cmdutil.AsMap(data)
output.KVWithTitle([]output.KVPair{
{Key: "ID", Value: output.Resolve(m, "id")},
{Key: "Code", Value: output.Resolve(m, "code")},
{Key: "Name", Value: output.Resolve(m, "namePrimary")},
{Key: "Name (EN)", Value: output.Resolve(m, "nameSecondary")},
{Key: "Level", Value: output.Resolve(m, "educationLevel.name")},
{Key: "Category", Value: output.Resolve(m, "category.name")},
{Key: "Class type", Value: output.Resolve(m, "classType.name")},
{Key: "Gradation", Value: output.Resolve(m, "gradation.name")},
{Key: "Course type", Value: output.Resolve(m, "type.name")},
}, "Course")

if sections, ok := m["sections"].([]any); ok && len(sections) > 0 {
fmt.Println()
output.Bold(" Sections")
rows := cmdutil.RowsFromAny(sections)
output.Table(rows, []output.Column{
{Header: "ID", Key: "id"},
{Header: "Code", Key: "code"},
{Header: "Semester", Key: "semester.name"},
{Header: "Campus", Key: "campus.name"},
})
}
return nil
return runCourseView(cmd, args[0])
},
}
}

func runCourseView(cmd *cobra.Command, id string) error {
c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false)
if err != nil {
return err
}
jwID, err := cmdutil.Int64PtrIfSet(id)
if err != nil {
return err
}
data, err := api.ParseResponseRaw(c.GetCourse(api.Ctx(), *jwID, nil))
if err != nil {
return err
}
if output.IsJSON() {
return output.JSON(data)
}
m := cmdutil.AsMap(data)
output.KVWithTitle([]output.KVPair{
{Key: "ID", Value: output.Resolve(m, "id")},
{Key: "Code", Value: output.Resolve(m, "code")},
{Key: "Name", Value: output.Resolve(m, "namePrimary")},
{Key: "Name (EN)", Value: output.Resolve(m, "nameSecondary")},
{Key: "Level", Value: output.Resolve(m, "educationLevel.name")},
{Key: "Category", Value: output.Resolve(m, "category.name")},
{Key: "Class type", Value: output.Resolve(m, "classType.name")},
{Key: "Gradation", Value: output.Resolve(m, "gradation.name")},
{Key: "Course type", Value: output.Resolve(m, "type.name")},
}, "Course")

if sections, ok := m["sections"].([]any); ok && len(sections) > 0 {
fmt.Println()
output.Bold(" Sections")
rows := cmdutil.RowsFromAny(sections)
output.Table(rows, []output.Column{
{Header: "ID", Key: "id"},
{Header: "Code", Key: "code"},
{Header: "Semester", Key: "semester.name"},
{Header: "Campus", Key: "campus.name"},
})
}
return nil
}
11 changes: 11 additions & 0 deletions internal/cmd/course/course_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package course

import "testing"

func TestCourseListUsesViewIdentifier(t *testing.T) {
columns := courseListColumns()
last := columns[len(columns)-1]
if last.Header != "JW ID" || last.Key != "jwId" {
t.Fatalf("last column = %#v, want JW ID backed by jwId", last)
}
}
111 changes: 59 additions & 52 deletions internal/cmd/section/section.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ func runSectionList(cmd *cobra.Command, opts sectionListOpts) error {
}
return tui.TableResult{Rows: list.Rows, Total: list.Total, Page: list.Page}, nil
},
OnSelect: func(row map[string]any) error {
return runSectionView(cmd, fmt.Sprint(row["jwId"]))
},
EmptyMessage: "No sections found. Try a broader search.",
})
}
Expand Down Expand Up @@ -156,7 +159,7 @@ func sectionListColumns() []output.Column {
{Header: "Course", Key: "course.namePrimary"},
{Header: "Semester", Key: "semester.name"},
{Header: "Campus", Key: "campus.name"},
{Header: "ID", Key: "id"},
{Header: "JW ID", Key: "jwId"},
}
}

Expand Down Expand Up @@ -194,60 +197,64 @@ func newCmdView() *cobra.Command {
Short: "View section details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false)
if err != nil {
return err
}
jwID, err := cmdutil.Int64PtrIfSet(args[0])
if err != nil {
return err
}
data, err := api.ParseResponseRaw(c.GetSection(api.Ctx(), *jwID))
if err != nil {
return err
}
if output.IsJSON() {
return output.JSON(data)
}
m := cmdutil.AsMap(data)
output.KVWithTitle([]output.KVPair{
{Key: "ID", Value: output.Resolve(m, "id")},
{Key: "Code", Value: output.Resolve(m, "code")},
{Key: "Course", Value: output.Resolve(m, "course.namePrimary")},
{Key: "Semester", Value: output.Resolve(m, "semester.name")},
{Key: "Campus", Value: output.Resolve(m, "campus.name")},
}, "Section")
return runSectionView(cmd, args[0])
},
}
}

if teachers, ok := m["teachers"].([]any); ok && len(teachers) > 0 {
fmt.Println()
output.Bold(" Teachers")
rows := cmdutil.RowsFromAny(teachers)
output.Table(rows, []output.Column{
{Header: "ID", Key: "id"},
{Header: "Name", Key: "namePrimary"},
{Header: "Name (EN)", Key: "nameSecondary"},
{Header: "Department", Key: "department.name"},
})
}
func runSectionView(cmd *cobra.Command, id string) error {
c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false)
if err != nil {
return err
}
jwID, err := cmdutil.Int64PtrIfSet(id)
if err != nil {
return err
}
data, err := api.ParseResponseRaw(c.GetSection(api.Ctx(), *jwID, nil))
if err != nil {
return err
}
if output.IsJSON() {
return output.JSON(data)
}
m := cmdutil.AsMap(data)
output.KVWithTitle([]output.KVPair{
{Key: "ID", Value: output.Resolve(m, "id")},
{Key: "Code", Value: output.Resolve(m, "code")},
{Key: "Course", Value: output.Resolve(m, "course.namePrimary")},
{Key: "Semester", Value: output.Resolve(m, "semester.name")},
{Key: "Campus", Value: output.Resolve(m, "campus.name")},
}, "Section")

if schedules, ok := m["schedules"].([]any); ok && len(schedules) > 0 {
fmt.Println()
output.Bold(" Schedules")
rows := cmdutil.RowsFromAny(schedules)
for _, row := range rows {
normalizeScheduleRow(row)
}
output.Table(rows, []output.Column{
{Header: "ID", Key: "id"},
{Header: "Day", Key: "weekday"},
{Header: "Start", Key: "startTime"},
{Header: "End", Key: "endTime"},
{Header: "Place", Key: "customPlace"},
})
}
return nil
},
if teachers, ok := m["teachers"].([]any); ok && len(teachers) > 0 {
fmt.Println()
output.Bold(" Teachers")
rows := cmdutil.RowsFromAny(teachers)
output.Table(rows, []output.Column{
{Header: "ID", Key: "id"},
{Header: "Name", Key: "namePrimary"},
{Header: "Name (EN)", Key: "nameSecondary"},
{Header: "Department", Key: "department.name"},
})
}

if schedules, ok := m["schedules"].([]any); ok && len(schedules) > 0 {
fmt.Println()
output.Bold(" Schedules")
rows := cmdutil.RowsFromAny(schedules)
for _, row := range rows {
normalizeScheduleRow(row)
}
output.Table(rows, []output.Column{
{Header: "ID", Key: "id"},
{Header: "Day", Key: "weekday"},
{Header: "Start", Key: "startTime"},
{Header: "End", Key: "endTime"},
{Header: "Place", Key: "customPlace"},
})
}
return nil
}

func newCmdSchedules() *cobra.Command {
Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/section/section_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,11 @@ func TestNormalizeScheduleRow_FullRow(t *testing.T) {
t.Errorf("endTime: got %v, want 10:40", got)
}
}

func TestSectionListUsesViewIdentifier(t *testing.T) {
columns := sectionListColumns()
last := columns[len(columns)-1]
if last.Header != "JW ID" || last.Key != "jwId" {
t.Fatalf("last column = %#v, want JW ID backed by jwId", last)
}
}
Loading
Loading