Skip to content

Commit e8f2be2

Browse files
authored
add combined http and cli lesson type (#72)
* add combined http and cli lesson type * version 1.15.0
1 parent 5f57ee6 commit e8f2be2

File tree

8 files changed

+798
-117
lines changed

8 files changed

+798
-117
lines changed

Diff for: checks/checks.go

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package checks
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"strings"
12+
"time"
13+
14+
api "github.com/bootdotdev/bootdev/client"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) {
19+
finalCommand := InterpolateVariables(command.Command, variables)
20+
result.FinalCommand = finalCommand
21+
22+
cmd := exec.Command("sh", "-c", finalCommand)
23+
cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8")
24+
b, err := cmd.CombinedOutput()
25+
if ee, ok := err.(*exec.ExitError); ok {
26+
result.ExitCode = ee.ExitCode()
27+
} else if err != nil {
28+
result.ExitCode = -2
29+
}
30+
result.Stdout = strings.TrimRight(string(b), " \n\t\r")
31+
result.Variables = variables
32+
return result
33+
}
34+
35+
func runHTTPRequest(
36+
client *http.Client,
37+
baseURL string,
38+
variables map[string]string,
39+
requestStep api.CLIStepHTTPRequest,
40+
) (
41+
result api.HTTPRequestResult,
42+
) {
43+
if baseURL == "" && requestStep.Request.FullURL == "" {
44+
cobra.CheckErr("no base URL or full URL provided")
45+
}
46+
47+
finalBaseURL := strings.TrimSuffix(baseURL, "/")
48+
interpolatedPath := InterpolateVariables(requestStep.Request.Path, variables)
49+
completeURL := fmt.Sprintf("%s%s", finalBaseURL, interpolatedPath)
50+
if requestStep.Request.FullURL != "" {
51+
completeURL = InterpolateVariables(requestStep.Request.FullURL, variables)
52+
}
53+
54+
var req *http.Request
55+
if requestStep.Request.BodyJSON != nil {
56+
dat, err := json.Marshal(requestStep.Request.BodyJSON)
57+
cobra.CheckErr(err)
58+
interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables)
59+
req, err = http.NewRequest(requestStep.Request.Method, completeURL,
60+
bytes.NewBuffer([]byte(interpolatedBodyJSONStr)),
61+
)
62+
if err != nil {
63+
cobra.CheckErr("Failed to create request")
64+
}
65+
req.Header.Add("Content-Type", "application/json")
66+
} else {
67+
var err error
68+
req, err = http.NewRequest(requestStep.Request.Method, completeURL, nil)
69+
if err != nil {
70+
cobra.CheckErr("Failed to create request")
71+
}
72+
}
73+
74+
for k, v := range requestStep.Request.Headers {
75+
req.Header.Add(k, InterpolateVariables(v, variables))
76+
}
77+
78+
if requestStep.Request.BasicAuth != nil {
79+
req.SetBasicAuth(requestStep.Request.BasicAuth.Username, requestStep.Request.BasicAuth.Password)
80+
}
81+
82+
if requestStep.Request.Actions.DelayRequestByMs != nil {
83+
time.Sleep(time.Duration(*requestStep.Request.Actions.DelayRequestByMs) * time.Millisecond)
84+
}
85+
86+
resp, err := client.Do(req)
87+
if err != nil {
88+
result = api.HTTPRequestResult{Err: "Failed to fetch"}
89+
return result
90+
}
91+
defer resp.Body.Close()
92+
93+
body, err := io.ReadAll(resp.Body)
94+
if err != nil {
95+
result = api.HTTPRequestResult{Err: "Failed to read response body"}
96+
return result
97+
}
98+
99+
headers := make(map[string]string)
100+
for k, v := range resp.Header {
101+
headers[k] = strings.Join(v, ",")
102+
}
103+
104+
parseVariables(body, requestStep.ResponseVariables, variables)
105+
106+
result = api.HTTPRequestResult{
107+
StatusCode: resp.StatusCode,
108+
ResponseHeaders: headers,
109+
BodyString: truncateAndStringifyBody(body),
110+
Variables: variables,
111+
Request: requestStep,
112+
}
113+
return result
114+
}
115+
116+
func CLIChecks(cliData api.CLIData, submitBaseURL *string) (results []api.CLIStepResult) {
117+
client := &http.Client{}
118+
variables := make(map[string]string)
119+
results = make([]api.CLIStepResult, len(cliData.Steps))
120+
121+
// use cli arg url if specified or default lesson data url
122+
baseURL := ""
123+
if submitBaseURL != nil && *submitBaseURL != "" {
124+
baseURL = *submitBaseURL
125+
} else if cliData.BaseURL != nil && *cliData.BaseURL != "" {
126+
baseURL = *cliData.BaseURL
127+
}
128+
129+
for i, step := range cliData.Steps {
130+
switch {
131+
case step.CLICommand != nil:
132+
result := runCLICommand(*step.CLICommand, variables)
133+
results[i].CLICommandResult = &result
134+
case step.HTTPRequest != nil:
135+
result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest)
136+
results[i].HTTPRequestResult = &result
137+
if result.Variables != nil {
138+
variables = result.Variables
139+
}
140+
default:
141+
cobra.CheckErr("unable to run lesson: missing step")
142+
}
143+
}
144+
return results
145+
}

Diff for: client/auth.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func LoginWithCode(code string) (*LoginResponse, error) {
6161
}
6262

6363
if resp.StatusCode == 403 {
64-
return nil, errors.New("The code you entered was invalid. Try refreshing your browser and trying again.")
64+
return nil, errors.New("invalid login code, please refresh your browser then try again")
6565
}
6666

6767
if resp.StatusCode != 200 {

Diff for: client/lessons.go

+86
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,50 @@ type LessonDataCLICommand struct {
8484
}
8585
}
8686

87+
type LessonDataCLI struct {
88+
// Readme string
89+
CLIData CLIData
90+
}
91+
92+
type CLIData struct {
93+
// ContainsCompleteDir bool
94+
BaseURL *string
95+
Steps []struct {
96+
CLICommand *CLIStepCLICommand
97+
HTTPRequest *CLIStepHTTPRequest
98+
}
99+
}
100+
101+
type CLIStepCLICommand struct {
102+
Command string
103+
Tests []CLICommandTestCase
104+
}
105+
106+
type CLIStepHTTPRequest struct {
107+
ResponseVariables []ResponseVariable
108+
Tests []HTTPTest
109+
Request struct {
110+
Method string
111+
Path string
112+
FullURL string // overrides BaseURL and Path if set
113+
Headers map[string]string
114+
BodyJSON map[string]interface{}
115+
BasicAuth *struct {
116+
Username string
117+
Password string
118+
}
119+
Actions struct {
120+
DelayRequestByMs *int32
121+
}
122+
}
123+
}
124+
87125
type Lesson struct {
88126
Lesson struct {
89127
Type string
90128
LessonDataHTTPTests *LessonDataHTTPTests
91129
LessonDataCLICommand *LessonDataCLICommand
130+
LessonDataCLI *LessonDataCLI
92131
}
93132
}
94133

@@ -150,6 +189,7 @@ type CLICommandResult struct {
150189
ExitCode int
151190
FinalCommand string `json:"-"`
152191
Stdout string
192+
Variables map[string]string
153193
}
154194

155195
func SubmitCLICommandLesson(uuid string, results []CLICommandResult) (*StructuredErrCLICommand, error) {
@@ -172,3 +212,49 @@ func SubmitCLICommandLesson(uuid string, results []CLICommandResult) (*Structure
172212
}
173213
return &failure, nil
174214
}
215+
216+
type HTTPRequestResult struct {
217+
Err string `json:"-"`
218+
StatusCode int
219+
ResponseHeaders map[string]string
220+
BodyString string
221+
Variables map[string]string
222+
Request CLIStepHTTPRequest
223+
}
224+
225+
type CLIStepResult struct {
226+
CLICommandResult *CLICommandResult
227+
HTTPRequestResult *HTTPRequestResult
228+
}
229+
230+
type lessonSubmissionCLI struct {
231+
CLIResults []CLIStepResult
232+
}
233+
234+
type StructuredErrCLI struct {
235+
ErrorMessage string `json:"Error"`
236+
FailedStepIndex int `json:"FailedStepIndex"`
237+
FailedTestIndex int `json:"FailedTestIndex"`
238+
}
239+
240+
func SubmitCLILesson(uuid string, results []CLIStepResult) (*StructuredErrCLI, error) {
241+
bytes, err := json.Marshal(lessonSubmissionCLI{CLIResults: results})
242+
if err != nil {
243+
return nil, err
244+
}
245+
endpoint := fmt.Sprintf("/v1/lessons/%v/", uuid)
246+
resp, code, err := fetchWithAuthAndPayload("POST", endpoint, bytes)
247+
if err != nil {
248+
return nil, err
249+
}
250+
if code != 200 {
251+
return nil, fmt.Errorf("failed to submit CLI lesson (code: %v): %s", code, string(resp))
252+
}
253+
var failure StructuredErrCLI
254+
err = json.Unmarshal(resp, &failure)
255+
if err != nil || failure.ErrorMessage == "" {
256+
// this is ok - it means we had success
257+
return nil, nil
258+
}
259+
return &failure, nil
260+
}

Diff for: cmd/submit.go

+15-3
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
3535
if err != nil {
3636
return err
3737
}
38-
switch lesson.Lesson.Type {
39-
case "type_http_tests":
38+
switch {
39+
case lesson.Lesson.Type == "type_http_tests" && lesson.Lesson.LessonDataHTTPTests != nil:
4040
results, _ := checks.HttpTest(*lesson, &submitBaseURL)
4141
data := *lesson.Lesson.LessonDataHTTPTests
4242
if isSubmit {
@@ -48,7 +48,7 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
4848
} else {
4949
render.HTTPRun(data, results)
5050
}
51-
case "type_cli_command":
51+
case lesson.Lesson.Type == "type_cli_command" && lesson.Lesson.LessonDataCLICommand != nil:
5252
results := checks.CLICommand(*lesson)
5353
data := *lesson.Lesson.LessonDataCLICommand
5454
if isSubmit {
@@ -60,6 +60,18 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
6060
} else {
6161
render.CommandRun(data, results)
6262
}
63+
case lesson.Lesson.Type == "type_cli" && lesson.Lesson.LessonDataCLI != nil:
64+
data := lesson.Lesson.LessonDataCLI.CLIData
65+
results := checks.CLIChecks(data, &submitBaseURL)
66+
if isSubmit {
67+
failure, err := api.SubmitCLILesson(lessonUUID, results)
68+
if err != nil {
69+
return err
70+
}
71+
render.RenderSubmission(data, results, failure)
72+
} else {
73+
render.RenderRun(data, results)
74+
}
6375
default:
6476
return errors.New("unsupported lesson type")
6577
}

Diff for: render/command.go

+24-28
Original file line numberDiff line numberDiff line change
@@ -137,34 +137,6 @@ func (m cmdRootModel) View() string {
137137
return str
138138
}
139139

140-
func prettyPrintCmd(test api.CLICommandTestCase) string {
141-
if test.ExitCode != nil {
142-
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
143-
}
144-
if test.StdoutLinesGt != nil {
145-
return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
146-
}
147-
if test.StdoutContainsAll != nil {
148-
str := "Expect stdout to contain all of:"
149-
for _, thing := range test.StdoutContainsAll {
150-
str += fmt.Sprintf("\n - '%s'", thing)
151-
}
152-
return str
153-
}
154-
if test.StdoutContainsNone != nil {
155-
str := "Expect stdout to contain none of:"
156-
for _, thing := range test.StdoutContainsNone {
157-
str += fmt.Sprintf("\n - '%s'", thing)
158-
}
159-
return str
160-
}
161-
return ""
162-
}
163-
164-
func pointerToBool(a bool) *bool {
165-
return &a
166-
}
167-
168140
func CommandRun(
169141
data api.LessonDataCLICommand,
170142
results []api.CLICommandResult,
@@ -260,3 +232,27 @@ func commandRenderer(
260232
}()
261233
wg.Wait()
262234
}
235+
236+
func prettyPrintCmd(test api.CLICommandTestCase) string {
237+
if test.ExitCode != nil {
238+
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
239+
}
240+
if test.StdoutLinesGt != nil {
241+
return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
242+
}
243+
if test.StdoutContainsAll != nil {
244+
str := "Expect stdout to contain all of:"
245+
for _, thing := range test.StdoutContainsAll {
246+
str += fmt.Sprintf("\n - '%s'", thing)
247+
}
248+
return str
249+
}
250+
if test.StdoutContainsNone != nil {
251+
str := "Expect stdout to contain none of:"
252+
for _, thing := range test.StdoutContainsNone {
253+
str += fmt.Sprintf("\n - '%s'", thing)
254+
}
255+
return str
256+
}
257+
return ""
258+
}

0 commit comments

Comments
 (0)