-
Notifications
You must be signed in to change notification settings - Fork 82
Added Webclient, refactored Webserver to share streamed body sending and receiving logic. #2771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
jurgenvinju
wants to merge
70
commits into
main
Choose a base branch
from
feat/webclient
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 8 commits
Commits
Show all changes
70 commits
Select commit
Hold shift + click to select a range
10d71ba
added initial webclient API that mirrors the Webserver API and uses t…
jurgenvinju 645d334
cleanup and added POST method
jurgenvinju 61cadea
added the other methods
jurgenvinju 6cce6de
added progress bar
jurgenvinju 17723ff
fixed post
jurgenvinju b9c1e6e
constructor typo
jurgenvinju 8c9c0da
fix post bug
jurgenvinju ddf4bfa
added path to other requests kinds but GET and POST
jurgenvinju 6b37e74
Merge branch 'main' into feat/webclient
jurgenvinju 53ae8a0
started rewrite of Server and Client interface to canonically treat a…
jurgenvinju cd8bdda
big cleanup of Webclient, but Webserver is broken now and I still hav…
jurgenvinju 8c51344
debugging with @davylandman
jurgenvinju 748dcc7
linked up the Subscription API as well to complete the stream
jurgenvinju ae4eefe
improving error handling of common mistakes in the client
jurgenvinju 9f7b1b5
factored out Writer-based suppliers
jurgenvinju 06b20ad
added asserts to diagnose possible race
jurgenvinju e1f8367
error handling for bad URLs
jurgenvinju 76abafe
removed dead use of parameter
jurgenvinju bee60e8
this seems to have fixed the race
jurgenvinju 345585e
comments
jurgenvinju 548aac6
deal with null messages of IOException generally
jurgenvinju db4c552
fixed off-by-one in download progress
jurgenvinju 0ad2cb2
rewrote the webserver side to accept the new Body constructors send a…
jurgenvinju c1ffd9e
server is working again
jurgenvinju 0f450c2
added xml and html sending and receiving, only server side reception …
jurgenvinju 73f21f0
refactoring that factors common WriterToInputStream functionality int…
jurgenvinju f9416a0
fixing issues with module management in client and server
jurgenvinju 7bc2ee5
rationalized JSON options
jurgenvinju 50faa40
cleanup of Webservice module and added some example usages
jurgenvinju 111d4f4
linked charset parameter of POST and PUT bodies
jurgenvinju 8123624
finished charset and mimetype propagation to POSt and PUT headers
jurgenvinju c969047
wired server side mimeType and response for bodies
jurgenvinju 6a047d3
fixed copy/paste bug
jurgenvinju c0311de
fixed switch case missing break bug
jurgenvinju 58c5ff2
added missing file
jurgenvinju 49c6a4e
added more headers
jurgenvinju b9a1c6a
Merge branch 'main' into feat/webclient
jurgenvinju c9134db
fixing more comments by @davylandman
jurgenvinju fe1b7a0
wrote a test server that does not lock the interpreter but does use a…
jurgenvinju 6869703
some fixes. not yet working
jurgenvinju 0304c83
test servers are working
jurgenvinju 9018c05
writing first good tests. they still fail
jurgenvinju 223ea5e
fixing things one-by-one
jurgenvinju 9953b5e
fixed sloppy NPEs
jurgenvinju 98e32c4
fixed content-type for POST and PUT bodies in client
jurgenvinju e4f2434
big rewrite to Undertow modern HTTP server in Java, removed clone of …
jurgenvinju 97fca4d
cleaning up
jurgenvinju 6573779
one more compilation error
jurgenvinju 3021254
fixed some warnings to get overview back
jurgenvinju 0b96380
bumped to Java 17 because Undertow requires it
jurgenvinju 6fb2d38
solved issues with new yield keyword in Java 17
jurgenvinju 9464ac0
bumped action workflow to Java 17
jurgenvinju d3031ad
fixing stuff after jump to Java 17
jurgenvinju f86e26e
some more fixes to get the server working again. almost there
jurgenvinju 2dd1a67
fixes and cleanup
jurgenvinju f07d333
can call test server with builtin client
jurgenvinju d008c72
fixed sending and receiving HTMLElement instances over http (client/s…
jurgenvinju 78ddeef
added XML roundtrip testing
jurgenvinju faec97e
fixed XML roundtripping
jurgenvinju aa64b22
added HTMLOptions for later use by requests
jurgenvinju 3f2dcb0
rationalized and finished JSON options
jurgenvinju e45b380
threaded HTML options
jurgenvinju bb3ffd0
streamed XML options too
jurgenvinju db2fbfa
added xmlOptions to BodyKind
jurgenvinju 434fcea
enabled automatic port allocation for util::Webserver
jurgenvinju 155ebaa
added precision parameter
jurgenvinju 1e91b7f
added precision parameter
jurgenvinju 2ace0d9
workaround
jurgenvinju 9219d22
a rather difficult merge
jurgenvinju 07539c0
Merge branch 'main' into feat/webclient
jurgenvinju File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,368 @@ | ||
| package org.rascalmpl.library.util; | ||
|
|
||
| import java.io.FilterInputStream; | ||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.io.InputStreamReader; | ||
| import java.io.InterruptedIOException; | ||
| import java.io.OutputStream; | ||
| import java.io.OutputStreamWriter; | ||
| import java.io.PipedInputStream; | ||
| import java.io.PipedOutputStream; | ||
| import java.io.StringWriter; | ||
| import java.net.http.HttpClient; | ||
| import java.net.http.HttpRequest; | ||
| import java.net.http.HttpRequest.BodyPublishers; | ||
| import java.net.http.HttpResponse; | ||
| import java.util.stream.Collectors; | ||
| import java.io.Writer; | ||
| import org.rascalmpl.debug.IRascalMonitor; | ||
| import org.rascalmpl.exceptions.RuntimeExceptionFactory; | ||
| import org.rascalmpl.library.Prelude; | ||
| import org.rascalmpl.library.lang.json.internal.JsonValueReader; | ||
| import org.rascalmpl.library.lang.json.internal.JsonValueWriter; | ||
| import org.rascalmpl.types.TypeReifier; | ||
| import org.rascalmpl.uri.URIResolverRegistry; | ||
| import org.rascalmpl.uri.URIUtil; | ||
| import org.rascalmpl.values.IRascalValueFactory; | ||
| import org.rascalmpl.values.functions.IFunction; | ||
|
|
||
| import com.google.gson.stream.JsonReader; | ||
| import com.google.gson.stream.JsonWriter; | ||
|
|
||
| import fi.iki.elonen.NanoHTTPD.Response.Status; | ||
| import io.usethesource.vallang.IConstructor; | ||
| import io.usethesource.vallang.ISourceLocation; | ||
| import io.usethesource.vallang.IString; | ||
| import io.usethesource.vallang.type.Type; | ||
| import io.usethesource.vallang.type.TypeFactory; | ||
| import io.usethesource.vallang.type.TypeStore; | ||
|
|
||
| public class Webclient { | ||
| private final IRascalValueFactory vf; | ||
| private final IRascalMonitor monitor; | ||
| private final TypeStore store; | ||
| private final TypeFactory tf; | ||
| private final TypeReifier tr; | ||
|
|
||
| public Webclient(IRascalValueFactory vf, IRascalMonitor monitor, TypeStore store, TypeFactory tf) { | ||
| this.vf = vf; | ||
| this.monitor = monitor; | ||
| this.store = store; | ||
| this.tf = tf; | ||
| this.tr = new TypeReifier(vf); | ||
| } | ||
|
|
||
| private HttpRequest makeGetRequest(IConstructor input) { | ||
| var params = input.asWithKeywordParameters(); | ||
| var host = ((ISourceLocation) params.getParameter("host")); | ||
| host = host == null ? URIUtil.assumeCorrectLocation("http://www.example.com") : host; | ||
| var path = ((IString) input.get("path")).getValue(); | ||
|
|
||
| return HttpRequest.newBuilder() | ||
| .uri(URIUtil.getChildLocation(host, path).getURI()) | ||
| .GET() | ||
| .build(); | ||
| } | ||
|
|
||
| private HttpRequest makePutRequest(IConstructor input) { | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| var params = input.asWithKeywordParameters(); | ||
| var postBody = (IFunction) input.get("body"); | ||
| var rt = new TypeReifier(vf).typeToValue(tf.stringType(), store, vf.map()); | ||
| var host = ((ISourceLocation) params.getParameter("host")); | ||
| host = host == null ? URIUtil.assumeCorrectLocation("http://www.example.com") : host; | ||
| var path = ((IString) input.get("path")).getValue(); | ||
|
|
||
| return HttpRequest.newBuilder() | ||
| .uri(URIUtil.getChildLocation(host, path).getURI()) | ||
| .PUT(HttpRequest.BodyPublishers.ofString(((IString) postBody.call(rt)).getValue())) | ||
| .build(); | ||
| } | ||
|
|
||
| private HttpRequest makeDeleteRequest(IConstructor input) { | ||
| var params = input.asWithKeywordParameters(); | ||
| var host = ((ISourceLocation) params.getParameter("host")); | ||
| host = host == null ? URIUtil.assumeCorrectLocation("http://www.example.com") : host; | ||
| var path = ((IString) input.get("path")).getValue(); | ||
|
|
||
| return HttpRequest.newBuilder() | ||
| .uri(URIUtil.getChildLocation(host, path).getURI()) | ||
| .DELETE() | ||
| .build(); | ||
| } | ||
|
|
||
| private HttpRequest makeHeadRequest(IConstructor input) { | ||
| var params = input.asWithKeywordParameters(); | ||
| var host = ((ISourceLocation) params.getParameter("host")); | ||
| host = host == null ? URIUtil.assumeCorrectLocation("http://www.example.com") : host; | ||
| var path = ((IString) input.get("path")).getValue(); | ||
|
|
||
| return HttpRequest.newBuilder() | ||
| .uri(URIUtil.getChildLocation(host, path).getURI()) | ||
| .method("HEAD", BodyPublishers.noBody()) | ||
| .build(); | ||
| } | ||
|
|
||
| private HttpRequest makePostRequest(IConstructor input) { | ||
| var params = input.asWithKeywordParameters(); | ||
| var postBody = (IFunction) input.get("content"); | ||
| var rt = new TypeReifier(vf).typeToValue(tf.valueType(), store, vf.map()); | ||
| var host = ((ISourceLocation) params.getParameter("host")); | ||
| var val = postBody.call(rt); | ||
| var path = ((IString) input.get("path")).getValue(); | ||
|
|
||
| try { | ||
| PipedOutputStream out = new PipedOutputStream(); | ||
| PipedInputStream in = new PipedInputStream(out, 64 * 1024); | ||
|
|
||
| Thread writer = new Thread(() -> { | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| try (OutputStream os = out; Writer w = new OutputStreamWriter(out)) { | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| JsonWriter jsonWriter = new JsonWriter(w); | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| JsonValueWriter jsonOut = new JsonValueWriter(); | ||
| jsonOut.write(jsonWriter, val); | ||
| } | ||
| catch (Exception e) { | ||
| throw RuntimeExceptionFactory.io(e.getMessage()); | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| } | ||
| }); | ||
|
|
||
| writer.start(); | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
|
|
||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| return HttpRequest.newBuilder() | ||
| .uri(URIUtil.getChildLocation(host, path).getURI()) | ||
| .POST(HttpRequest.BodyPublishers.ofInputStream(() -> in)) | ||
| .build(); | ||
| } | ||
| catch (IOException e) { | ||
| throw RuntimeExceptionFactory.io(e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| private HttpRequest makeRequest(IConstructor input) { | ||
| switch (input.getName()) { | ||
| case "get": | ||
| return makeGetRequest(input); | ||
| case "post": | ||
| return makePostRequest(input); | ||
| case "put": | ||
| return makePutRequest(input); | ||
| case "delete": | ||
| return makeDeleteRequest(input); | ||
| case "head": | ||
| return makeHeadRequest(input); | ||
|
|
||
| default: | ||
| throw RuntimeExceptionFactory.illegalArgument(input); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * This is the main API method for the Rascal side | ||
| */ | ||
| public IConstructor fetch(IConstructor input) { | ||
| try { | ||
| var request = makeRequest(input); | ||
| var response = HttpClient | ||
| .newBuilder() | ||
| .followRedirects(HttpClient.Redirect.NORMAL) | ||
| .build() | ||
|
jurgenvinju marked this conversation as resolved.
|
||
| .send(request, HttpResponse.BodyHandlers.ofInputStream()); | ||
| return translateResponse(request.uri().toString(), (IConstructor) input.asWithKeywordParameters().getParameter("body"), response); | ||
| } | ||
| catch (IOException | InterruptedException e) { | ||
| throw RuntimeExceptionFactory.io(e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| private IConstructor translateResponse(String url, IConstructor expect, HttpResponse<InputStream> response) throws IOException { | ||
| var headers = response | ||
| .headers() | ||
| .map() | ||
| .entrySet() | ||
| .stream() | ||
| .map(e -> vf.tuple( | ||
| vf.string(e.getKey()), | ||
| vf.string(e.getValue().stream().collect(Collectors.joining(",")) | ||
| ))) | ||
| .collect(vf.mapWriter()); | ||
|
|
||
| long totalBytes = response.headers() | ||
| .firstValueAsLong("Content-Length") | ||
| .orElse(-1); | ||
|
|
||
| var input = totalBytes > 0 | ||
| ? new MonitoredInputStream(response.body(), monitor, "Fetching " + url, totalBytes) | ||
| : response.body(); | ||
|
|
||
| var contentType = response.headers().firstValue("Content-Type"); | ||
|
|
||
| var mimeType = vf.string(contentType.get().split(";")[0]); | ||
|
|
||
| // TODO: extract from contentType if present | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| var charset = "utf-8"; | ||
|
|
||
| var status = toStatusConstructor(response.statusCode()); | ||
|
|
||
| Type respCons; | ||
| IString body; | ||
|
|
||
| switch (expect != null ? expect.getName() : "textBody") { | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| case "jsonBody": | ||
| JsonReader jsonReader = new JsonReader(new InputStreamReader(input)); | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| JsonValueReader parser = new JsonValueReader(vf, store, monitor, URIUtil.assumeCorrectLocation(url)); | ||
| respCons = store.lookupConstructors("jsonResponse").iterator().next(); | ||
| var value = parser.read(jsonReader, tr.valueToType((IConstructor) expect.get("expected"))); | ||
| return vf.constructor(respCons, status, headers, value); | ||
| case "fileBody": | ||
| respCons = store.lookupConstructors("fileResponse").iterator().next(); | ||
| var loc = (ISourceLocation) expect.get("storage"); | ||
| try (OutputStream out = URIResolverRegistry.getInstance().getOutputStream(loc, false)) { | ||
| input.transferTo(out); | ||
| } | ||
| return vf.constructor(respCons, loc, mimeType, headers); | ||
| case "textBody": | ||
| default: | ||
| respCons = store.lookupConstructors("response").iterator().next(); | ||
| body = vf.string(new String(Prelude.consumeInputStream(input), charset)); | ||
|
jurgenvinju marked this conversation as resolved.
Outdated
|
||
| return vf.constructor(respCons, status, mimeType, headers, body); | ||
| } | ||
| } | ||
|
|
||
| private IConstructor toStatusConstructor(int stCode) { | ||
| var statusType = store.lookupAbstractDataType("Status"); | ||
|
|
||
| var status = Status.lookup(stCode); | ||
| switch (status) { | ||
| case OK: | ||
| return vf.constructor(store.lookupConstructor(statusType, "ok", tf.tupleEmpty())); | ||
| case NOT_FOUND: | ||
| return vf.constructor(store.lookupConstructor(statusType, "notFound", tf.tupleEmpty())); | ||
| case ACCEPTED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "accepted", tf.tupleEmpty())); | ||
| case BAD_REQUEST: | ||
| return vf.constructor(store.lookupConstructor(statusType, "badRequest", tf.tupleEmpty())); | ||
| case CONFLICT: | ||
| return vf.constructor(store.lookupConstructor(statusType, "conflict", tf.tupleEmpty())); | ||
| case CREATED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "create", tf.tupleEmpty())); | ||
| case EXPECTATION_FAILED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "expectationFailed", tf.tupleEmpty())); | ||
| case FORBIDDEN: | ||
| return vf.constructor(store.lookupConstructor(statusType, "forbidden", tf.tupleEmpty())); | ||
| case FOUND: | ||
| return vf.constructor(store.lookupConstructor(statusType, "found", tf.tupleEmpty())); | ||
| case GONE: | ||
| return vf.constructor(store.lookupConstructor(statusType, "gone", tf.tupleEmpty())); | ||
| case INTERNAL_ERROR: | ||
| return vf.constructor(store.lookupConstructor(statusType, "internalError", tf.tupleEmpty())); | ||
| case LENGTH_REQUIRED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "lengthRequired", tf.tupleEmpty())); | ||
| case METHOD_NOT_ALLOWED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "methodNotAllowed", tf.tupleEmpty())); | ||
| case MULTI_STATUS: | ||
| return vf.constructor(store.lookupConstructor(statusType, "multiStatus", tf.tupleEmpty())); | ||
| case NOT_ACCEPTABLE: | ||
| return vf.constructor(store.lookupConstructor(statusType, "notAcceptible", tf.tupleEmpty())); | ||
| case NOT_IMPLEMENTED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "notImplemented", tf.tupleEmpty())); | ||
| case NOT_MODIFIED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "notModified", tf.tupleEmpty())); | ||
| case NO_CONTENT: | ||
| return vf.constructor(store.lookupConstructor(statusType, "noContent", tf.tupleEmpty())); | ||
| case PARTIAL_CONTENT: | ||
| return vf.constructor(store.lookupConstructor(statusType, "partialContent", tf.tupleEmpty())); | ||
| case PAYLOAD_TOO_LARGE: | ||
| return vf.constructor(store.lookupConstructor(statusType, "payloadTooLarge", tf.tupleEmpty())); | ||
| case PRECONDITION_FAILED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "preconditionFailed", tf.tupleEmpty())); | ||
| case RANGE_NOT_SATISFIABLE: | ||
| return vf.constructor(store.lookupConstructor(statusType, "rangeNotSatisfieable", tf.tupleEmpty())); | ||
| case REDIRECT: | ||
| return vf.constructor(store.lookupConstructor(statusType, "redirect", tf.tupleEmpty())); | ||
| case REDIRECT_SEE_OTHER: | ||
| return vf.constructor(store.lookupConstructor(statusType, "redirectSeeOther", tf.tupleEmpty())); | ||
| case REQUEST_TIMEOUT: | ||
| return vf.constructor(store.lookupConstructor(statusType, "requestTimeout", tf.tupleEmpty())); | ||
| case SERVICE_UNAVAILABLE: | ||
| return vf.constructor(store.lookupConstructor(statusType, "serviceUnavailable", tf.tupleEmpty())); | ||
| case SWITCH_PROTOCOL: | ||
| return vf.constructor(store.lookupConstructor(statusType, "switchProtocol", tf.tupleEmpty())); | ||
| case TEMPORARY_REDIRECT: | ||
| return vf.constructor(store.lookupConstructor(statusType, "temporaryRedirect", tf.tupleEmpty())); | ||
| case TOO_MANY_REQUESTS: | ||
| return vf.constructor(store.lookupConstructor(statusType, "tooManyRequests", tf.tupleEmpty())); | ||
| case UNAUTHORIZED: | ||
| return vf.constructor(store.lookupConstructor(statusType, "unauthorized", tf.tupleEmpty())); | ||
| case UNSUPPORTED_HTTP_VERSION: | ||
| return vf.constructor(store.lookupConstructor(statusType, "unsupportedHTTPVersion", tf.tupleEmpty())); | ||
| case UNSUPPORTED_MEDIA_TYPE: | ||
| return vf.constructor(store.lookupConstructor(statusType, "unsupportedMediaType", tf.tupleEmpty())); | ||
| default: | ||
| // if we don't understand the error code; let's call it an internal error | ||
| return vf.constructor(store.lookupConstructor(statusType, "internalError", tf.tupleEmpty())); | ||
| } | ||
| } | ||
|
|
||
| private class MonitoredInputStream extends FilterInputStream { | ||
| private final IRascalMonitor monitor; | ||
| private final String jobName; | ||
|
|
||
| private final long totalBytes; | ||
| private long bytesRead = 0; | ||
| private boolean started = false; | ||
| private boolean done = false; | ||
|
|
||
| public MonitoredInputStream(InputStream in, IRascalMonitor monitor, String jobName, long totalBytes) { | ||
| super(in); | ||
| this.totalBytes = totalBytes; | ||
| this.monitor = monitor; | ||
| this.jobName = jobName; | ||
| } | ||
|
|
||
| private void ensureStarted() { | ||
| if (!started) { | ||
| started = true; | ||
| monitor.jobStart(jobName, Integer.MAX_VALUE); | ||
| } | ||
| } | ||
|
|
||
| private void updateProgress(int bytesRead) throws InterruptedIOException { | ||
| if (monitor.jobIsCanceled(jobName)) { | ||
| throw new InterruptedIOException(jobName); | ||
| } | ||
|
|
||
| ensureStarted(); | ||
| long numberOfTheseSteps = (int) (totalBytes / bytesRead); | ||
| int stepSize = (int) (Integer.MAX_VALUE / numberOfTheseSteps); | ||
| monitor.jobStep(jobName, "", java.lang.Math.max(stepSize, 1)); | ||
| checkDone(); | ||
| } | ||
|
|
||
| private void checkDone() { | ||
| if (!done && bytesRead >= totalBytes) { | ||
| done = true; | ||
| monitor.jobEnd(jobName, true); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public int read() throws IOException { | ||
| int b = super.read(); | ||
| if (b != -1) { | ||
| bytesRead += 1; | ||
| updateProgress(1); | ||
| } | ||
| return b; | ||
| } | ||
|
|
||
| @Override | ||
| public int read(byte[] b, int off, int len) throws IOException { | ||
| int n = super.read(b, off, len); | ||
| if (n > 0) { | ||
| bytesRead += n; | ||
| updateProgress(n); | ||
| } | ||
| return n; | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.