Master de II. ULL. 1er cuatrimestre
Al aceptar la asignación se le pedirá el nombre del equipo. Deberá darle como nombre
Nombre-Apellidos-aluXXX
(sin acentos ni caracteres especiales). Los equipos son de un sólo miembro.
Write an express web app that shows the published GH cli extensions sorted by stars. Use The GitHub GRaphQL API to get the data.
1
2
3
4
const express = require('express');
const app = express()
const { ApolloClient, InMemoryCache, HttpLink, gql } = require('@apollo/client');
const fetch = require('node-fetch');
The gql
function is a tagged template literal. Tagged template literals are literals delimited with backticks (`) that call a function (called the tag function) with
gql
returns the AST of the query. See this repl node session:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
> let { ApolloClient, InMemoryCache, HttpLink, gql } = require('@apollo/client');
undefined
> result = gql`
... query GetDogs {
... dogs {
..... id
..... }
... }
... `
{
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: [Object],
variableDefinitions: [],
directives: [],
selectionSet: [Object]
}
],
loc: { start: 0, end: 43 }
}
You can run queries on real GitHub data using the GraphQL Explorer, an integrated development environment in your browser that includes docs, syntax highlighting, and validation errors.
Visit https://docs.github.com/en/graphql/overview/explorer
Let us try to design a query to obtain the published GH Cli extensions sorted by stars.
Let us go to the documentation for search
in the GraphQL API. It give us s.t. like:
Type: SearchResultItemConnection!
Name | Description |
---|---|
|
Returns the elements in the list that come after the specified cursor. |
|
Returns the elements in the list that come before the specified cursor. |
|
Returns the first n elements from the list. |
|
Returns the last n elements from the list. |
|
The search string to look for. |
|
The types of search items to search within. |
We see also that result of a search has the type SearchResultItemConnection that describes the list of results that matched against our search query.
To fill the query
field we need to find out how to search for "gh-extensions"
.
Let us go to the GitHub docs for search syntax. We need to read:
Try different searches at https://github.com/search/advanced
We see that result of a GraphQL search has the type SearchResultItemConnection that describes the list of results that matched against our search query.
These are the fields:
Name | Description |
---|---|
|
The number of pieces of code that matched the search query. |
|
The number of discussions that matched the search query. |
|
A list of edges. |
|
The number of issues that matched the search query. |
|
A list of nodes. |
|
Information to aid in pagination. |
|
The number of repositories that matched the search query. |
|
The number of users that matched the search query. |
|
The number of wiki pages that matched the search query. |
The result type is a SearchResultItemConnection which means is a special kind of Connection.
The concept of Connection belongs to GraphQL.
A connection is a collection of objects with metadata such as edges
, pageInfo
, etc.
The pageInfo
object contains information as
Name | Description |
---|---|
|
When paginating forwards, the cursor to continue. |
|
When paginating forwards, are there more items?. |
|
When paginating backwards, are there more items?. |
|
When paginating backwards, the cursor to continue. |
hasNextPage
will tell us if there are more edges
available, or if we’ve reached the end of this connection.
Edges will provide us with flexibility to use our data. In the example of a search, the edges are of type SearchResultItemEdge
Name | Description |
---|---|
|
A cursor for use in pagination. |
|
The item at the end of the edge. |
|
Text matches on the result found. |
edges will help us for the pagination: Each edge has a node
which is a record or a data and a cursor
that is a base64 encoded string to help relay with pagination.
In our example the nodes are of the type SearchResultItem that can be almost anything since they are a union type.
Unions do not define any fields, so no fields may be queried on this type without the use of type refining fragments or inline fragments1.
Fragments are the primary unit of composition in GraphQL.
Fragments allow for the reuse of common repeated selections of fields, reducing duplicated text in the document. Inline Fragments can be used directly within a selection to condition upon a type condition when querying against an interface or union2.
For example,let’s say we had a page in our app, which lets us look at the marks of two students side by side. You can imagine that such a query implies to repeat the fields at least once - one for each side of the comparison.
query compare2studentsNoFragment($id1: String!, $id2: String!) {
left: student(AluXXXX: $id1) {
Nombre
AluXXXX
markdown
}
right: student(AluXXXX: $id2) {
Nombre
AluXXXX
markdown
}
}
The repeated fields could be extracted into a fragment and composed by a parent fragment or query.
fragment studentInfo on Student {
Nombre
AluXXXX
markdown
}
query compare2students($id1: String!, $id2: String!) {
# fragment example
left: student(AluXXXX: $id1) {
... studentInfo
}
right: student(AluXXXX: $id2) {
... studentInfo
}
}
If you have a sharp eye, you may have noticed that, since the result object fields match the name of the field in the query but don’t include arguments, you can’t directly query for the same field with different arguments.
That’s why you need aliases - they let you rename the result of a field to anything you want.
In the above example, thw two student
fields would have conflicted, but
since we can alias them to different names left
and right
, we can get both results in one request.
Default values can also be assigned to the variables in the query by adding the default value after the type declaration.
query getStudent($id1: String = "232566@studenti.unimore.it") {
student(AluXXXX: $id1) {
Nombre
markdown
}
}
After what we have explained, we can make an attempt trying this query in the explorer:
{
search(query: "topic:gh-extension sort:stars", type: REPOSITORY, first: 2 ) {
repositoryCount
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
... on Repository {
nameWithOwner
description
url
stargazers {
totalCount
}
}
}
}
}
}
that give us the number of repositories corresponding to gh-extensions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"data": {
"search": {
"repositoryCount": 111,
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yOjI="
},
"edges": [
{
"cursor": "Y3Vyc29yOjE=",
"node": {
"nameWithOwner": "mislav/gh-branch",
"description": "GitHub CLI extension for fuzzy finding, quickly switching between and deleting branches.",
"url": "https://github.com/mislav/gh-branch",
"stargazers": {
"totalCount": 117
}
}
},
{
"cursor": "Y3Vyc29yOjI=",
"node": {
"nameWithOwner": "vilmibm/gh-screensaver",
"description": "full terminal animations",
"url": "https://github.com/vilmibm/gh-screensaver",
"stargazers": {
"totalCount": 63
}
}
}
]
}
}
}
Now we can use the endCursor
to make the next request.
1
2
3
4
5
6
7
8
9
const cache = new InMemoryCache();
const client = new ApolloClient({
link: new HttpLink({
uri: "https://api.github.com/graphql",
fetch,
headers: { 'Authorization': `Bearer ${process.env['GITHUB_TOKEN']}`, },
}),
cache
});
HttpLink is a terminating link that sends a GraphQL operation to a remote endpoint over HTTP.
The HttpLink constructor takes an options object that can include:
The uri
parameter is the URL of the GraphQL endpoint to send requests to.
The default value is /graphql
.
The fetch
parameter is a function to use instead of calling the Fetch API directly when sending HTTP requests to your GraphQL endpoint. The function must conform to the signature of fetch
.
The headers
parameter is an object representing headers to include in every HTTP request.
In an endpoint-based API, clients can use HTTP caching to easily avoid refetching resources, and for identifying when two resources are the same. The URL in these APIs is a globally unique identifier that the client can leverage to build a cache.
HTTP cache semantics allow for cacheable responses to be stored locally by clients, moving the cache away from the server-side and closer to the client for better performance and reduced network dependence.
To support this, HTTP makes available several caching options through the Cache-Control
response header that defines if the response is cacheable and, if so, for how long. Responses may only be cached if the HTTP method is GET
or HEAD
and the proper Cache-Control
header indicates the content is cacheable.
In GraphQL, though, there’s no URL-like primitive that provides this globally unique identifier for a given object. It’s hence a best practice for the API to expose such an identifier for clients to use.
See the section Caching in Apollo Client for details on how the Apollo Client stores the results of your GraphQL queries in a local, normalized, in-memory cache. This enables Apollo Client to respond immediately to queries for already-cached data, without sending a network request.
See Pagination in Apollo Client
Now the handler for requests to the main /
route is simple:
1
2
3
4
5
6
app.get('/', function(req, res) {
client
.query({query: firstQuery})
.then(queryRes => handler(res, queryRes))
.catch(error => console.error(error))
});
We make the GraphQL query firstQuery
using the query
method of the ApolloClient client
that it is sent to the GitHub GraphQL API endpoint. It returns a Promise.
When the promise is fulfilled, we get in the variable queryRes
the response of the GitHub server.
We pass both our object res
to elaborate the response and the queryRes
object to our handler
function that renders the results of the query:
1
2
3
4
5
6
7
8
9
10
const handler = (res, r) => {
const result = r.data.search;
const repos = result.edges;
const repoCount = result.repositoryCount
if (result.pageInfo.hasNextPage) {
let lastCursor = result.pageInfo.endCursor;
res.render('pages/index', { repos: repos, lastCursor: lastCursor});
}
}
The first call to handler
gets the array repos
corresponding to the first page of json objects describing the matching repositories.
Then it computes lastCursor
, the cursor of the last item in the incoming page.
Both the array repos
and lastCursor
are passed to the view in views/pages/index.ejs
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body class="container">
<header>
<%- include('../partials/header'); %>
</header>
<main>
<div class="jumbotron">
<h1>Most Starred GitHub Cli Extensions</h1>
<ol start="<%= offset %>">
<% repos.forEach(function(repo) { %>
<li>
<a href="<%= repo.node.url %>" target="_blank">
<strong><%= repo.node.nameWithOwner %></strong>
</a>
<%= repo.node.description %>
</li>
<% }); %>
</ol>
<a href="/next/<%= lastCursor %>">
<button type="button" class="btn btn-primary btn-sm float-right">Next extensions</button>
</a>
</main>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>
A request to the route /next/:cursor
is sent when the link button
to get the next page of gh cli extensions is clicked. The parameter :cursor
is
filled with the value of the last cursor of the previous page.
1
2
3
4
5
6
app.get('/next/:cursor', function(req, res) {
client
.query({ query: subsequentQueries(req.params.cursor)})
.then(queryRes => handler(res,queryRes))
.catch(error => console.error(error))
});
where subsequentQueries
only differs from the first query in the after: $cursor
argument:
{
search(query: "topic:gh-extension sort:stars", type: REPOSITORY, first: 2, after: $cursor) {
repositoryCount
pageInfo {
hasNextPage
endCursor
}
edges {
...
}
}
}
After we set the app
to listen for requests
1
app.listen(7000, () => console.log(`App listening on port 7000!`))
we can visit the page with our browser: